diff --git a/.eslintrc.js b/.eslintrc.js index dfb65dc11e..34a1d792a2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,11 +5,11 @@ module.exports = { '!.eslintrc.js', '!jest.config.js', 'node_modules', - 'dist', - 'docs', - 'coverage', + '**/dist', + '**/docs', + '**/coverage', 'merged-packages', - 'package-template', + 'scripts/create-package/package-template', ], overrides: [ { 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 0000000000..fcbe290483 --- /dev/null +++ b/.github/workflows/ensure-blocking-pr-labels-absent.yml @@ -0,0 +1,30 @@ +name: 'Check for PR labels that block merging' +on: + pull_request: + types: + - opened + - labeled + - unlabeled + +jobs: + ensure-blocking-pr-labels-absent: + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: actions/checkout@v3 + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + cache: yarn + - name: Install dependencies + run: yarn --immutable + - name: Run command + uses: actions/github-script@v7 + 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/security-code-scanner.yml b/.github/workflows/security-code-scanner.yml new file mode 100644 index 0000000000..d2be2998f4 --- /dev/null +++ b/.github/workflows/security-code-scanner.yml @@ -0,0 +1,30 @@ +name: 'MetaMask Security Code Scanner' + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + +jobs: + run-security-scan: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - name: MetaMask Security Code Scanner + uses: MetaMask/Security-Code-Scanner@main + with: + repo: ${{ github.repository }} + paths_ignored: | + '**/test*/' + docs/ + '**/*.test.js' + '**/*.test.ts' + node_modules + merged-packages/ + '**/jest.environment.js' + mixpanel_project_token: ${{secrets.SECURITY_CODE_SCANNER_MIXPANEL_TOKEN}} + slack_webhook: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} diff --git a/package.json b/package.json index c244be1171..b2477e2fce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "115.0.0", + "version": "120.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { @@ -44,7 +44,7 @@ "@babel/core": "^7.23.5", "@babel/plugin-transform-modules-commonjs": "^7.23.3", "@babel/preset-typescript": "^7.23.3", - "@lavamoat/allow-scripts": "^2.3.1", + "@lavamoat/allow-scripts": "^3.0.2", "@metamask/create-release-branch": "^3.0.0", "@metamask/eslint-config": "^12.2.0", "@metamask/eslint-config-jest": "^12.1.0", @@ -92,11 +92,6 @@ "@lavamoat/preinstall-always-fail": false, "@keystonehq/bc-ur-registry-eth>hdkey>secp256k1": true, "babel-runtime>core-js": false, - "eth-sig-util>ethereumjs-abi>ethereumjs-util>keccakjs>sha3": true, - "eth-sig-util>ethereumjs-util>keccak": true, - "eth-sig-util>ethereumjs-util>secp256k1": true, - "ethereumjs-util>ethereum-cryptography>keccak": true, - "ethereumjs-util>ethereum-cryptography>secp256k1": true, "simple-git-hooks": false } } diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index a9615c2623..b28c3b7841 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -31,6 +31,7 @@ "test:watch": "jest --watch" }, "dependencies": { + "@ethereumjs/util": "^8.1.0", "@metamask/base-controller": "^4.1.1", "@metamask/eth-snap-keyring": "^2.1.1", "@metamask/keyring-api": "^3.0.0", @@ -38,7 +39,7 @@ "@metamask/snaps-utils": "^5.1.2", "@metamask/utils": "^8.3.0", "deepmerge": "^4.2.2", - "ethereumjs-util": "^7.0.10", + "ethereum-cryptography": "^2.1.2", "immer": "^9.0.6", "uuid": "^8.3.2" }, diff --git a/packages/accounts-controller/src/AccountsController.test.ts b/packages/accounts-controller/src/AccountsController.test.ts index ca23c4dd97..0cbc502796 100644 --- a/packages/accounts-controller/src/AccountsController.test.ts +++ b/packages/accounts-controller/src/AccountsController.test.ts @@ -1401,7 +1401,7 @@ describe('AccountsController', () => { KeyringTypes.ledger, KeyringTypes.lattice, KeyringTypes.qr, - KeyringTypes.custody, + 'Custody - JSON - RPC', ])('should add accounts for %s type', async (keyringType) => { mockUUID.mockReturnValue('mock-id'); diff --git a/packages/accounts-controller/src/AccountsController.ts b/packages/accounts-controller/src/AccountsController.ts index dc72d15de3..1ab6078919 100644 --- a/packages/accounts-controller/src/AccountsController.ts +++ b/packages/accounts-controller/src/AccountsController.ts @@ -1,3 +1,4 @@ +import { toBuffer } from '@ethereumjs/util'; import type { ControllerGetStateAction, ControllerStateChangeEvent, @@ -22,7 +23,7 @@ import type { import type { SnapId } from '@metamask/snaps-sdk'; import type { Snap } from '@metamask/snaps-utils'; import type { Keyring, Json } from '@metamask/utils'; -import { sha256FromString } from 'ethereumjs-util'; +import { sha256 } from 'ethereum-cryptography/sha256'; import type { Draft } from 'immer'; import { v4 as uuid } from 'uuid'; @@ -455,7 +456,7 @@ export class AccountsController extends BaseController< address, ); const v4options = { - random: sha256FromString(address).slice(0, 16), + random: sha256(toBuffer(address)).slice(0, 16), }; internalAccounts.push({ diff --git a/packages/accounts-controller/src/utils.ts b/packages/accounts-controller/src/utils.ts index be620a73ac..9888b31c2d 100644 --- a/packages/accounts-controller/src/utils.ts +++ b/packages/accounts-controller/src/utils.ts @@ -1,5 +1,6 @@ -import { KeyringTypes } from '@metamask/keyring-controller'; -import { sha256FromString } from 'ethereumjs-util'; +import { toBuffer } from '@ethereumjs/util'; +import { isCustodyKeyring, KeyringTypes } from '@metamask/keyring-controller'; +import { sha256 } from 'ethereum-cryptography/sha256'; import { v4 as uuid } from 'uuid'; /** @@ -9,6 +10,12 @@ import { v4 as uuid } from 'uuid'; * @returns The name of the keyring type. */ export function keyringTypeToName(keyringType: string): string { + // Custody keyrings are a special case, as they are not a single type + // they just start with the prefix `Custody` + if (isCustodyKeyring(keyringType)) { + return 'Custody'; + } + switch (keyringType) { case KeyringTypes.simple: { return 'Account'; @@ -31,9 +38,6 @@ export function keyringTypeToName(keyringType: string): string { case KeyringTypes.snap: { return 'Snap Account'; } - case KeyringTypes.custody: { - return 'Custody'; - } default: { throw new Error(`Unknown keyring ${keyringType}`); } @@ -47,7 +51,7 @@ export function keyringTypeToName(keyringType: string): string { */ export function getUUIDFromAddressOfNormalAccount(address: string): string { const v4options = { - random: sha256FromString(address).slice(0, 16), + random: sha256(toBuffer(address)).slice(0, 16), }; return uuid(v4options); diff --git a/packages/address-book-controller/package.json b/packages/address-book-controller/package.json index f448299fdb..fea33b1ce7 100644 --- a/packages/address-book-controller/package.json +++ b/packages/address-book-controller/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/utils": "^8.3.0" }, "devDependencies": { diff --git a/packages/approval-controller/package.json b/packages/approval-controller/package.json index 9fa474ebe1..dc34ace734 100644 --- a/packages/approval-controller/package.json +++ b/packages/approval-controller/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/utils": "^8.3.0", "nanoid": "^3.1.31" }, diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index ec0b658185..fe0b264076 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -10,17 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **BREAKING:** Adds `@metamask/accounts-controller` ^8.0.0 and `@metamask/keyring-controller` ^12.0.0 as dependencies and peer dependencies. ([#3775](https://github.com/MetaMask/core/pull/3775/)). -- **BREAKING:** `TokenDetectionController` newly subscribes to the `PreferencesController:stateChange`, `AccountsController:selectedAccountChange`, `KeyringController:lock`, `KeyringController:unlock` events, and allows the `PreferencesController:getState` messenger action. ([#3775](https://github.com/MetaMask/core/pull/3775/)) +- **BREAKING:** `TokenDetectionController` newly subscribes to the `PreferencesController:stateChange`, `AccountsController:selectedAccountChange`, `KeyringController:lock`, `KeyringController:unlock` events, and allows messenger actions `AccountsController:getSelectedAccount`, `NetworkController:findNetworkClientIdByChainId`, `NetworkController:getNetworkConfigurationByNetworkClientId`, `NetworkController:getProviderConfig`, `KeyringController:getState`, `PreferencesController:getState`, `TokenListController:getState`, `TokensController:getState`, `TokensController:addDetectedTokens`. ([#3775](https://github.com/MetaMask/core/pull/3775/)), ([#3923](https://github.com/MetaMask/core/pull/3923/)) - `TokensController` now exports `TokensControllerActions`, `TokensControllerGetStateAction`, `TokensControllerAddDetectedTokensAction`, `TokensControllerEvents`, `TokensControllerStateChangeEvent`. ([#3690](https://github.com/MetaMask/core/pull/3690/)) ### Changed -- **BREAKING:** `TokenDetectionController` is merged with `DetectTokensController` from the `metamask-extension` repo. ([#3775](https://github.com/MetaMask/core/pull/3775/)) +- **BREAKING:** `TokenDetectionController` is merged with `DetectTokensController` from the `metamask-extension` repo. ([#3775](https://github.com/MetaMask/core/pull/3775/), [#3923](https://github.com/MetaMask/core/pull/3923)), ([#3938](https://github.com/MetaMask/core/pull/3938)) - **BREAKING:** `TokenDetectionController` now resets its polling interval to the default value of 3 minutes when token detection is triggered by external controller events `KeyringController:unlock`, `TokenListController:stateChange`, `PreferencesController:stateChange`, `AccountsController:selectedAccountChange`. - **BREAKING:** `TokenDetectionController` now refetches tokens on `NetworkController:networkDidChange` if the `networkClientId` is changed instead of `chainId`. - **BREAKING:** `TokenDetectionController` cannot initiate polling or token detection if `KeyringController` state is locked. + - **BREAKING:** The `detectTokens` method input option `accountAddress` has been renamed to `selectedAddress`. - **BREAKING:** The `detectTokens` method now excludes tokens that are already included in the `TokensController`'s `detectedTokens` list from the batch of incoming tokens it sends to the `TokensController` `addDetectedTokens` method. - **BREAKING:** The constructor for `TokenDetectionController` expects a new required proprerty `trackMetaMetricsEvent`, which defines the callback that is called in the `detectTokens` method. + - The constructor option `selectedAddress` no longer defaults to `''` if omitted. Instead, the correct address is assigned using the `AccountsController:getSelectedAccount` messenger action. - **BREAKING:** In Mainnet, even if the `PreferenceController`'s `useTokenDetection` option is set to false, automatic token detection is performed on the legacy token list (token data from the contract-metadata repo). - **BREAKING:** The `TokensState` type is now defined as a type alias rather than an interface. ([#3690](https://github.com/MetaMask/core/pull/3690/)) - This is breaking because it could affect how this type is used with other types, such as `Json`, which does not support TypeScript interfaces. diff --git a/packages/assets-controllers/jest.config.js b/packages/assets-controllers/jest.config.js index 314fa1281f..ecc2975e25 100644 --- a/packages/assets-controllers/jest.config.js +++ b/packages/assets-controllers/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 88.47, - functions: 95.89, - lines: 96.87, - statements: 96.87, + branches: 88.58, + functions: 96.98, + lines: 97.35, + statements: 97.4, }, }, diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 6e848a1d71..f921440382 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -31,6 +31,7 @@ "test:watch": "jest --watch" }, "dependencies": { + "@ethereumjs/util": "^8.1.0", "@ethersproject/address": "^5.7.0", "@ethersproject/bignumber": "^5.7.0", "@ethersproject/contracts": "^5.7.0", @@ -40,19 +41,20 @@ "@metamask/approval-controller": "^5.1.2", "@metamask/base-controller": "^4.1.1", "@metamask/contract-metadata": "^2.4.0", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/eth-query": "^4.0.0", "@metamask/keyring-controller": "^12.2.0", "@metamask/metamask-eth-abis": "3.0.0", "@metamask/network-controller": "^17.2.0", "@metamask/polling-controller": "^5.0.0", "@metamask/preferences-controller": "^7.0.0", - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/utils": "^8.3.0", + "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", "async-mutex": "^0.2.6", + "bn.js": "^5.2.1", "cockatiel": "^3.1.2", - "ethereumjs-util": "^7.0.10", "lodash": "^4.17.21", "multiformats": "^9.5.2", "single-call-balance-checker-abi": "^1.0.0", diff --git a/packages/assets-controllers/src/AssetsContractController.ts b/packages/assets-controllers/src/AssetsContractController.ts index 973ebf7fd1..62bc0266bf 100644 --- a/packages/assets-controllers/src/AssetsContractController.ts +++ b/packages/assets-controllers/src/AssetsContractController.ts @@ -11,7 +11,7 @@ import type { } from '@metamask/network-controller'; import type { PreferencesState } 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'; diff --git a/packages/assets-controllers/src/NftController.test.ts b/packages/assets-controllers/src/NftController.test.ts index 889dba0cd4..810c4743c2 100644 --- a/packages/assets-controllers/src/NftController.test.ts +++ b/packages/assets-controllers/src/NftController.test.ts @@ -26,7 +26,7 @@ import { getDefaultPreferencesState, type PreferencesState, } from '@metamask/preferences-controller'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import nock from 'nock'; import * as sinon from 'sinon'; import { v4 } from 'uuid'; diff --git a/packages/assets-controllers/src/NftController.ts b/packages/assets-controllers/src/NftController.ts index 4597492aa1..0f8d258342 100644 --- a/packages/assets-controllers/src/NftController.ts +++ b/packages/assets-controllers/src/NftController.ts @@ -26,8 +26,9 @@ import type { import type { PreferencesState } 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 BN from 'bn.js'; import { EventEmitter } from 'events'; import { v4 as random } from 'uuid'; @@ -568,7 +569,7 @@ export class NftController extends BaseControllerV1 { 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]; diff --git a/packages/assets-controllers/src/NftDetectionController.test.ts b/packages/assets-controllers/src/NftDetectionController.test.ts index 12265fc81b..b5dacc77cf 100644 --- a/packages/assets-controllers/src/NftDetectionController.test.ts +++ b/packages/assets-controllers/src/NftDetectionController.test.ts @@ -570,6 +570,34 @@ describe('NftDetectionController', () => { }, ); }); + + it('should only re-detect when relevant settings change', async () => { + await withController( + {}, + async ({ controller, triggerPreferencesStateChange }) => { + const detectNfts = sinon.stub(controller, 'detectNfts'); + + // Repeated preference changes should only trigger 1 detection + for (let i = 0; i < 5; i++) { + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + } + await advanceTime({ clock, duration: 1 }); + expect(detectNfts.callCount).toBe(1); + + // Irrelevant preference changes shouldn't trigger a detection + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + securityAlertsEnabled: true, + }); + await advanceTime({ clock, duration: 1 }); + expect(detectNfts.callCount).toBe(1); + }, + ); + }); }); type WithControllerCallback = ({ diff --git a/packages/assets-controllers/src/NftDetectionController.ts b/packages/assets-controllers/src/NftDetectionController.ts index 88891bebd1..5b34ffc1e2 100644 --- a/packages/assets-controllers/src/NftDetectionController.ts +++ b/packages/assets-controllers/src/NftDetectionController.ts @@ -299,9 +299,6 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1< !useNftDetection !== disabled ) { this.configure({ selectedAddress, disabled: !useNftDetection }); - } - - if (useNftDetection !== undefined) { if (useNftDetection) { this.start(); } else { diff --git a/packages/assets-controllers/src/Standards/ERC20Standard.ts b/packages/assets-controllers/src/Standards/ERC20Standard.ts index ba32f987c7..9eadcd78b0 100644 --- a/packages/assets-controllers/src/Standards/ERC20Standard.ts +++ b/packages/assets-controllers/src/Standards/ERC20Standard.ts @@ -1,11 +1,11 @@ +import { toUtf8 } 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 type BN from 'bn.js'; import { ethersBigNumberToBN } from '../assetsUtil'; diff --git a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.ts b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.ts index afbb1a70d1..d11b7eb84a 100644 --- a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.ts +++ b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.ts @@ -9,7 +9,7 @@ import { 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'; diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 0797292ccb..01d023a8cd 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -1,6 +1,6 @@ import { ControllerMessenger } from '@metamask/base-controller'; import { toHex } from '@metamask/controller-utils'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import { flushPromises } from '../../../tests/helpers'; import type { diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index ba6522cedb..81d82ce848 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -8,18 +8,20 @@ import { } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-api'; import type { KeyringControllerState } from '@metamask/keyring-controller'; -import { - defaultState as defaultNetworkState, - type NetworkState, - type NetworkConfiguration, - type NetworkController, +import type { + NetworkState, + NetworkConfiguration, + NetworkController, + ProviderConfig, + NetworkClientId, } from '@metamask/network-controller'; +import { defaultState as defaultNetworkState } from '@metamask/network-controller'; import { getDefaultPreferencesState, type PreferencesState, } from '@metamask/preferences-controller'; import type { Hex } from '@metamask/utils'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import nock from 'nock'; import * as sinon from 'sinon'; @@ -32,6 +34,7 @@ import type { TokenDetectionControllerMessenger, } from './TokenDetectionController'; import { + STATIC_MAINNET_TOKEN_LIST, TokenDetectionController, controllerName, } from './TokenDetectionController'; @@ -40,7 +43,7 @@ import { type TokenListState, type TokenListToken, } from './TokenListController'; -import type { TokensState } from './TokensController'; +import type { TokensController, TokensState } from './TokensController'; import { getDefaultTokensState } from './TokensController'; const DEFAULT_INTERVAL = 180000; @@ -137,8 +140,11 @@ function buildTokenDetectionControllerMessenger( return controllerMessenger.getRestricted({ name: controllerName, allowedActions: [ + 'AccountsController:getSelectedAccount', 'KeyringController:getState', + 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getNetworkConfigurationByNetworkClientId', + 'NetworkController:getProviderConfig', 'TokensController:getState', 'TokensController:addDetectedTokens', 'TokenListController:getState', @@ -189,6 +195,61 @@ describe('TokenDetectionController', () => { clock.restore(); }); + it('should not poll and detect tokens on interval while keyring is locked', async () => { + await withController( + { + isKeyringUnlocked: false, + }, + async ({ controller }) => { + const mockTokens = sinon.stub(controller, 'detectTokens'); + controller.setIntervalLength(10); + + await controller.start(); + + expect(mockTokens.calledOnce).toBe(false); + await advanceTime({ clock, duration: 15 }); + expect(mockTokens.calledTwice).toBe(false); + }, + ); + }); + + it('should detect tokens but not restart polling if locked keyring is unlocked', async () => { + await withController( + { + isKeyringUnlocked: false, + }, + async ({ controller, triggerKeyringUnlock }) => { + const mockTokens = sinon.stub(controller, 'detectTokens'); + + await controller.start(); + triggerKeyringUnlock(); + + expect(mockTokens.calledOnce).toBe(true); + await advanceTime({ clock, duration: DEFAULT_INTERVAL * 1.5 }); + expect(mockTokens.calledTwice).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 = sinon.stub(controller, 'detectTokens'); + controller.setIntervalLength(10); + + await controller.start(); + triggerKeyringLock(); + + expect(mockTokens.calledOnce).toBe(true); + await advanceTime({ clock, duration: 15 }); + expect(mockTokens.calledTwice).toBe(false); + }, + ); + }); + it('should poll and detect tokens on interval while on supported networks', async () => { await withController(async ({ controller }) => { const mockTokens = sinon.stub(controller, 'detectTokens'); @@ -234,29 +295,30 @@ describe('TokenDetectionController', () => { selectedAddress, }, }, - async ({ - controller, - mockTokenListGetState, - mockAddDetectedTokens, - }) => { + async ({ controller, mockTokenListGetState, callActionSpy }) => { mockTokenListGetState({ ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokensChainsCache: { + '0x1': { + 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(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -283,27 +345,37 @@ describe('TokenDetectionController', () => { }, async ({ controller, + mockGetProviderConfig, mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, }) => { + mockGetProviderConfig({ + chainId: '0x89', + } as unknown as ProviderConfig); + mockTokenListGetState({ ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokensChainsCache: { + '0x89': { + 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(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -331,30 +403,31 @@ describe('TokenDetectionController', () => { selectedAddress, }, }, - async ({ - controller, - mockTokenListGetState, - mockAddDetectedTokens, - }) => { + async ({ controller, mockTokenListGetState, callActionSpy }) => { const tokenListState = { ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokensChainsCache: { + '0x1': { + 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.tokenList[sampleTokenB.address] = { - name: sampleTokenB.name as string, + tokenListState.tokensChainsCache['0x1'].data[sampleTokenB.address] = { + name: sampleTokenB.name, symbol: sampleTokenB.symbol, decimals: sampleTokenB.decimals, address: sampleTokenB.address, @@ -365,7 +438,7 @@ describe('TokenDetectionController', () => { mockTokenListGetState(tokenListState); await advanceTime({ clock, duration: interval }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA, sampleTokenB], { @@ -394,7 +467,7 @@ describe('TokenDetectionController', () => { controller, mockTokensGetState, mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, }) => { mockTokensGetState({ ...getDefaultTokensState(), @@ -402,22 +475,27 @@ describe('TokenDetectionController', () => { }); mockTokenListGetState({ ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokensChainsCache: { + '0x1': { + 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(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -436,29 +514,30 @@ describe('TokenDetectionController', () => { selectedAddress: '', }, }, - async ({ - controller, - mockTokenListGetState, - mockAddDetectedTokens, - }) => { + async ({ controller, mockTokenListGetState, callActionSpy }) => { mockTokenListGetState({ ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokensChainsCache: { + '0x1': { + 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(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -466,6 +545,242 @@ describe('TokenDetectionController', () => { }); }); + describe('AccountsController:selectedAccountChange', () => { + let clock: sinon.SinonFakeTimers; + beforeEach(() => { + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + 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 firstSelectedAddress = + '0x0000000000000000000000000000000000000001'; + const secondSelectedAddress = + '0x0000000000000000000000000000000000000002'; + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + networkClientId: NetworkType.mainnet, + selectedAddress: firstSelectedAddress, + }, + }, + async ({ + mockTokenListGetState, + triggerSelectedAccountChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + 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: secondSelectedAddress, + } as InternalAccount); + await advanceTime({ clock, duration: 1 }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + [sampleTokenA], + { + chainId: ChainId.mainnet, + selectedAddress: secondSelectedAddress, + }, + ); + }, + ); + }); + + it('should not detect new tokens if the account is unchanged', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAddress = '0x0000000000000000000000000000000000000001'; + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + networkClientId: NetworkType.mainnet, + selectedAddress, + }, + }, + async ({ + mockTokenListGetState, + triggerSelectedAccountChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + 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: selectedAddress, + } as InternalAccount); + await advanceTime({ clock, 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 firstSelectedAddress = + '0x0000000000000000000000000000000000000001'; + const secondSelectedAddress = + '0x0000000000000000000000000000000000000002'; + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + networkClientId: NetworkType.mainnet, + selectedAddress: firstSelectedAddress, + }, + isKeyringUnlocked: false, + }, + async ({ + mockTokenListGetState, + triggerSelectedAccountChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + 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: secondSelectedAddress, + } as InternalAccount); + await advanceTime({ clock, 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 firstSelectedAddress = + '0x0000000000000000000000000000000000000001'; + const secondSelectedAddress = + '0x0000000000000000000000000000000000000002'; + await withController( + { + options: { + disabled: true, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + networkClientId: NetworkType.mainnet, + selectedAddress: firstSelectedAddress, + }, + }, + async ({ + mockTokenListGetState, + triggerSelectedAccountChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + 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: secondSelectedAddress, + } as InternalAccount); + await advanceTime({ clock, duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + }); + }); + describe('PreferencesController:stateChange', () => { let clock: sinon.SinonFakeTimers; beforeEach(() => { @@ -476,7 +791,7 @@ describe('TokenDetectionController', () => { clock.restore(); }); - describe('when "disabled" is "false"', () => { + 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), @@ -497,19 +812,24 @@ describe('TokenDetectionController', () => { async ({ mockTokenListGetState, triggerPreferencesStateChange, - mockAddDetectedTokens, + callActionSpy, }) => { mockTokenListGetState({ ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokensChainsCache: { + '0x1': { + 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, + }, + }, }, }, }); @@ -521,7 +841,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -550,19 +870,24 @@ describe('TokenDetectionController', () => { async ({ mockTokenListGetState, triggerPreferencesStateChange, - mockAddDetectedTokens, + callActionSpy, }) => { mockTokenListGetState({ ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokensChainsCache: { + '0x1': { + 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, + }, + }, }, }, }); @@ -581,7 +906,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -613,13 +938,13 @@ describe('TokenDetectionController', () => { async ({ mockTokenListGetState, triggerPreferencesStateChange, - mockAddDetectedTokens, + callActionSpy, }) => { mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -637,7 +962,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -661,13 +986,13 @@ describe('TokenDetectionController', () => { async ({ mockTokenListGetState, triggerPreferencesStateChange, - mockAddDetectedTokens, + callActionSpy, }) => { mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -685,15 +1010,125 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + 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 firstSelectedAddress = + '0x0000000000000000000000000000000000000001'; + const secondSelectedAddress = + '0x0000000000000000000000000000000000000002'; + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + networkClientId: NetworkType.mainnet, + selectedAddress: firstSelectedAddress, + }, + isKeyringUnlocked: false, + }, + async ({ + mockTokenListGetState, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokenList: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + selectedAddress: secondSelectedAddress, + useTokenDetection: true, + }); + await advanceTime({ clock, 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 selectedAddress = '0x0000000000000000000000000000000000000001'; + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + networkClientId: NetworkType.mainnet, + selectedAddress, + }, + isKeyringUnlocked: false, + }, + async ({ + mockTokenListGetState, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokenList: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + selectedAddress, + useTokenDetection: false, + }); + await advanceTime({ clock, duration: 1 }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + selectedAddress, + useTokenDetection: true, + }); + await advanceTime({ clock, duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + }); }); - describe('when "disabled" is "true"', () => { + 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), @@ -714,13 +1149,13 @@ describe('TokenDetectionController', () => { async ({ mockTokenListGetState, triggerPreferencesStateChange, - mockAddDetectedTokens, + callActionSpy, }) => { mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -738,7 +1173,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -762,13 +1197,13 @@ describe('TokenDetectionController', () => { async ({ mockTokenListGetState, triggerPreferencesStateChange, - mockAddDetectedTokens, + callActionSpy, }) => { mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -793,7 +1228,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -812,7 +1247,7 @@ describe('TokenDetectionController', () => { clock.restore(); }); - describe('when "disabled" is "false"', () => { + describe('when "disabled" is false', () => { it('should detect new tokens after switching network client id', async () => { const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ [sampleTokenA.address]: new BN(1), @@ -829,20 +1264,25 @@ describe('TokenDetectionController', () => { }, async ({ mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, triggerNetworkDidChange, }) => { mockTokenListGetState({ ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokensChainsCache: { + '0x89': { + 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, + }, + }, }, }, }); @@ -853,7 +1293,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -881,20 +1321,25 @@ describe('TokenDetectionController', () => { }, async ({ mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, triggerNetworkDidChange, }) => { mockTokenListGetState({ ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokensChainsCache: { + '0x5': { + 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, + }, + }, }, }, }); @@ -905,7 +1350,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -928,14 +1373,14 @@ describe('TokenDetectionController', () => { }, async ({ mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, triggerNetworkDidChange, }) => { mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -952,15 +1397,65 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + 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 selectedAddress = '0x0000000000000000000000000000000000000001'; + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + networkClientId: NetworkType.mainnet, + selectedAddress, + }, + isKeyringUnlocked: false, + }, + async ({ + mockTokenListGetState, + callActionSpy, + triggerNetworkDidChange, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokenList: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }); + + triggerNetworkDidChange({ + ...defaultNetworkState, + selectedNetworkClientId: 'polygon', + }); + await advanceTime({ clock, duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + }); }); - describe('when "disabled" is "true"', () => { + 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), @@ -977,14 +1472,14 @@ describe('TokenDetectionController', () => { }, async ({ mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, triggerNetworkDidChange, }) => { mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -1001,7 +1496,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -1020,7 +1515,7 @@ describe('TokenDetectionController', () => { clock.restore(); }); - describe('when "disabled" is "false"', () => { + describe('when "disabled" is false', () => { it('should detect tokens if the token list is non-empty', async () => { const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ [sampleTokenA.address]: new BN(1), @@ -1037,20 +1532,27 @@ describe('TokenDetectionController', () => { }, async ({ mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, triggerTokenListStateChange, }) => { + const tokenList = { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }; const tokenListState = { ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokenList, + tokensChainsCache: { + '0x1': { + timestamp: 0, + data: tokenList, }, }, }; @@ -1059,7 +1561,7 @@ describe('TokenDetectionController', () => { triggerTokenListStateChange(tokenListState); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -1087,7 +1589,7 @@ describe('TokenDetectionController', () => { }, async ({ mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, triggerTokenListStateChange, }) => { const tokenListState = { @@ -1099,15 +1601,63 @@ describe('TokenDetectionController', () => { triggerTokenListStateChange(tokenListState); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, ); }); + + describe('when keyring is locked', () => { + it('should not detect tokens', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAddress = '0x0000000000000000000000000000000000000001'; + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + networkClientId: NetworkType.mainnet, + selectedAddress, + }, + isKeyringUnlocked: false, + }, + async ({ + mockTokenListGetState, + callActionSpy, + triggerTokenListStateChange, + }) => { + const tokenListState = { + ...getDefaultTokenListState(), + tokenList: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }; + mockTokenListGetState(tokenListState); + + triggerTokenListStateChange(tokenListState); + await advanceTime({ clock, duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + }); }); - describe('when "disabled" is "true"', () => { + describe('when "disabled" is true', () => { it('should not detect tokens', async () => { const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ [sampleTokenA.address]: new BN(1), @@ -1124,14 +1674,14 @@ describe('TokenDetectionController', () => { }, async ({ mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, triggerTokenListStateChange, }) => { const tokenListState = { ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -1146,7 +1696,7 @@ describe('TokenDetectionController', () => { triggerTokenListStateChange(tokenListState); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -1184,7 +1734,7 @@ describe('TokenDetectionController', () => { ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -1212,19 +1762,19 @@ describe('TokenDetectionController', () => { await advanceTime({ clock, duration: 0 }); expect(spy.mock.calls).toMatchObject([ - [{ networkClientId: 'mainnet', accountAddress: '0x1' }], - [{ networkClientId: 'sepolia', accountAddress: '0xdeadbeef' }], - [{ networkClientId: 'goerli', accountAddress: '0x3' }], + [{ networkClientId: 'mainnet', selectedAddress: '0x1' }], + [{ networkClientId: 'sepolia', selectedAddress: '0xdeadbeef' }], + [{ networkClientId: 'goerli', selectedAddress: '0x3' }], ]); await advanceTime({ clock, duration: DEFAULT_INTERVAL }); expect(spy.mock.calls).toMatchObject([ - [{ networkClientId: 'mainnet', accountAddress: '0x1' }], - [{ networkClientId: 'sepolia', accountAddress: '0xdeadbeef' }], - [{ networkClientId: 'goerli', accountAddress: '0x3' }], - [{ networkClientId: 'mainnet', accountAddress: '0x1' }], - [{ networkClientId: 'sepolia', accountAddress: '0xdeadbeef' }], - [{ networkClientId: 'goerli', accountAddress: '0x3' }], + [{ networkClientId: 'mainnet', selectedAddress: '0x1' }], + [{ networkClientId: 'sepolia', selectedAddress: '0xdeadbeef' }], + [{ networkClientId: 'goerli', selectedAddress: '0x3' }], + [{ networkClientId: 'mainnet', selectedAddress: '0x1' }], + [{ networkClientId: 'sepolia', selectedAddress: '0xdeadbeef' }], + [{ networkClientId: 'goerli', selectedAddress: '0x3' }], ]); }, ); @@ -1232,11 +1782,51 @@ describe('TokenDetectionController', () => { }); describe('detectTokens', () => { - it('should detect and add tokens by networkClientId correctly', async () => { + 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 selectedAddress = '0x0000000000000000000000000000000000000001'; + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + networkClientId: NetworkType.goerli, + selectedAddress, + }, + }, + async ({ + controller, + triggerPreferencesStateChange, + callActionSpy, + }) => { + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + await controller.detectTokens({ + networkClientId: NetworkType.goerli, + selectedAddress, + }); + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + + it('should detect and add tokens from the `@metamask/contract-metadata` legacy token list if token detection is disabled and current network is mainnet', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue( + Object.keys(STATIC_MAINNET_TOKEN_LIST).reduce>( + (acc, address) => { + acc[address] = new BN(1); + return acc; + }, + {}, + ), + ); + const selectedAddress = '0x0000000000000000000000000000000000000001'; await withController( { options: { @@ -1248,30 +1838,82 @@ describe('TokenDetectionController', () => { }, async ({ controller, - mockTokenListGetState, - mockAddDetectedTokens, + triggerPreferencesStateChange, + callActionSpy, }) => { + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + await controller.detectTokens({ + networkClientId: NetworkType.mainnet, + selectedAddress, + }); + expect(callActionSpy).toHaveBeenLastCalledWith( + 'TokensController:addDetectedTokens', + Object.values(STATIC_MAINNET_TOKEN_LIST).map((token) => { + const newToken = { + ...token, + image: token.iconUrl, + isERC721: false, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (newToken as any).erc20; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (newToken as any).erc721; + delete newToken.iconUrl; + return newToken; + }), + { + selectedAddress, + chainId: ChainId.mainnet, + }, + ); + }, + ); + }); + + it('should detect and add tokens by networkClientId correctly', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAddress = '0x0000000000000000000000000000000000000001'; + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + networkClientId: NetworkType.mainnet, + selectedAddress, + }, + }, + async ({ controller, mockTokenListGetState, callActionSpy }) => { mockTokenListGetState({ ...getDefaultTokenListState(), - tokenList: { - [sampleTokenA.address]: { - name: sampleTokenA.name as string, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokensChainsCache: { + '0x1': { + 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({ networkClientId: NetworkType.mainnet, - accountAddress: selectedAddress, + selectedAddress, }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -1282,6 +1924,62 @@ describe('TokenDetectionController', () => { }, ); }); + + it('should invoke the `trackMetaMetricsEvent` callback when token detection is triggered', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAddress = '0x0000000000000000000000000000000000000001'; + const mockTrackMetaMetricsEvent = jest.fn(); + + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + trackMetaMetricsEvent: mockTrackMetaMetricsEvent, + networkClientId: NetworkType.mainnet, + selectedAddress, + }, + }, + async ({ controller, mockTokenListGetState }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + 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({ + networkClientId: NetworkType.mainnet, + selectedAddress, + }); + + expect(mockTrackMetaMetricsEvent).toHaveBeenCalledWith({ + event: 'Token Detected', + category: 'Wallet', + properties: { + tokens: [`${sampleTokenA.symbol} - ${sampleTokenA.address}`], + token_standard: 'ERC20', + asset_type: 'TOKEN', + }, + }); + }, + ); + }); }); }); @@ -1299,11 +1997,15 @@ function getTokensPath(chainId: Hex) { type WithControllerCallback = ({ controller, + mockGetSelectedAccount, mockKeyringGetState, mockTokensGetState, mockTokenListGetState, mockPreferencesGetState, - mockAddDetectedTokens, + mockFindNetworkClientIdByChainId, + mockGetNetworkConfigurationByNetworkClientId, + mockGetProviderConfig, + callActionSpy, triggerKeyringUnlock, triggerKeyringLock, triggerTokenListStateChange, @@ -1312,14 +2014,19 @@ type WithControllerCallback = ({ triggerNetworkDidChange, }: { controller: TokenDetectionController; + mockGetSelectedAccount: (address: string) => void; mockKeyringGetState: (state: KeyringControllerState) => void; mockTokensGetState: (state: TokensState) => void; mockTokenListGetState: (state: TokenListState) => void; mockPreferencesGetState: (state: PreferencesState) => void; + mockFindNetworkClientIdByChainId: ( + handler: (chainId: Hex) => NetworkClientId, + ) => void; mockGetNetworkConfigurationByNetworkClientId: ( handler: (networkClientId: string) => NetworkConfiguration, ) => void; - mockAddDetectedTokens: jest.SpyInstance; + mockGetProviderConfig: (config: ProviderConfig) => void; + callActionSpy: jest.SpyInstance; triggerKeyringUnlock: () => void; triggerKeyringLock: () => void; triggerTokenListStateChange: (state: TokenListState) => void; @@ -1330,6 +2037,7 @@ type WithControllerCallback = ({ type WithControllerOptions = { options?: Partial[0]>; + isKeyringUnlocked?: boolean; messenger?: ControllerMessenger; }; @@ -1350,17 +2058,29 @@ async function withController( ...args: WithControllerArgs ): Promise { const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const { options, messenger } = rest; + const { options, isKeyringUnlocked, messenger } = rest; const controllerMessenger = messenger ?? new ControllerMessenger(); + const mockGetSelectedAccount = jest.fn(); + controllerMessenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + mockGetSelectedAccount.mockReturnValue({ + address: '0x1', + } as InternalAccount), + ); const mockKeyringState = jest.fn(); controllerMessenger.registerActionHandler( 'KeyringController:getState', mockKeyringState.mockReturnValue({ - isUnlocked: true, + isUnlocked: isKeyringUnlocked ?? true, } as KeyringControllerState), ); + const mockFindNetworkClientIdByChainId = jest.fn(); + controllerMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + mockFindNetworkClientIdByChainId.mockReturnValue(NetworkType.mainnet), + ); const mockGetNetworkConfigurationByNetworkClientId = jest.fn< ReturnType, Parameters @@ -1368,11 +2088,19 @@ async function withController( controllerMessenger.registerActionHandler( 'NetworkController:getNetworkConfigurationByNetworkClientId', mockGetNetworkConfigurationByNetworkClientId.mockImplementation( - (networkClientId: string) => { + (networkClientId: NetworkClientId) => { return mockNetworkConfigurations[networkClientId]; }, ), ); + const mockGetProviderConfig = jest.fn(); + controllerMessenger.registerActionHandler( + 'NetworkController:getProviderConfig', + mockGetProviderConfig.mockReturnValue({ + type: NetworkType.mainnet, + chainId: '0x1', + } as unknown as ProviderConfig), + ); const mockTokensState = jest.fn(); controllerMessenger.registerActionHandler( 'TokensController:getState', @@ -1390,7 +2118,16 @@ async function withController( ...getDefaultPreferencesState(), }), ); - const mockAddDetectedTokens = jest.spyOn(controllerMessenger, 'call'); + controllerMessenger.registerActionHandler( + 'TokensController:addDetectedTokens', + jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue(undefined), + ); + const callActionSpy = jest.spyOn(controllerMessenger, 'call'); const controller = new TokenDetectionController({ networkClientId: NetworkType.mainnet, @@ -1402,6 +2139,9 @@ async function withController( try { return await fn({ controller, + mockGetSelectedAccount: (address: string) => { + mockGetSelectedAccount.mockReturnValue({ address } as InternalAccount); + }, mockKeyringGetState: (state: KeyringControllerState) => { mockKeyringState.mockReturnValue(state); }, @@ -1414,14 +2154,22 @@ async function withController( mockTokenListGetState: (state: TokenListState) => { mockTokenListState.mockReturnValue(state); }, + mockFindNetworkClientIdByChainId: ( + handler: (chainId: Hex) => NetworkClientId, + ) => { + mockFindNetworkClientIdByChainId.mockImplementation(handler); + }, mockGetNetworkConfigurationByNetworkClientId: ( - handler: (networkClientId: string) => NetworkConfiguration, + handler: (networkClientId: NetworkClientId) => NetworkConfiguration, ) => { mockGetNetworkConfigurationByNetworkClientId.mockImplementation( handler, ); }, - mockAddDetectedTokens, + mockGetProviderConfig: (config: ProviderConfig) => { + mockGetProviderConfig.mockReturnValue(config); + }, + callActionSpy, triggerKeyringUnlock: () => { controllerMessenger.publish('KeyringController:unlock'); }, diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index e570cce454..d4d1cd8d2c 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -1,15 +1,14 @@ -import type { AccountsControllerSelectedAccountChangeEvent } from '@metamask/accounts-controller'; +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerSelectedAccountChangeEvent, +} from '@metamask/accounts-controller'; import type { RestrictedControllerMessenger, ControllerGetStateAction, ControllerStateChangeEvent, } from '@metamask/base-controller'; import contractMap from '@metamask/contract-metadata'; -import { - ChainId, - safelyExecute, - toChecksumHexAddress, -} from '@metamask/controller-utils'; +import { ChainId, safelyExecute } from '@metamask/controller-utils'; import type { KeyringControllerGetStateAction, KeyringControllerLockEvent, @@ -17,8 +16,10 @@ import type { } from '@metamask/keyring-controller'; import type { NetworkClientId, - NetworkControllerNetworkDidChangeEvent, + NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerGetNetworkConfigurationByNetworkClientId, + NetworkControllerGetProviderConfigAction, + NetworkControllerNetworkDidChangeEvent, } from '@metamask/network-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { @@ -43,13 +44,23 @@ import type { const DEFAULT_INTERVAL = 180000; /** - * Finds a case insensitive match in an array of strings - * @param source - An array of strings to search. - * @param target - The target string to search for. - * @returns The first match that is found. + * Compare 2 given strings and return boolean + * eg: "foo" and "FOO" => true + * eg: "foo" and "bar" => false + * eg: "foo" and 123 => false + * + * @param value1 - first string to compare + * @param value2 - first string to compare + * @returns true if 2 strings are identical when they are lowercase */ -function findCaseInsensitiveMatch(source: string[], target: string) { - return source.find((e: string) => e.toLowerCase() === target.toLowerCase()); +export function isEqualCaseInsensitive( + value1: string, + value2: string, +): boolean { + if (typeof value1 !== 'string' || typeof value2 !== 'string') { + return false; + } + return value1.toLowerCase() === value2.toLowerCase(); } type LegacyToken = Omit< @@ -95,7 +106,10 @@ export type TokenDetectionControllerActions = TokenDetectionControllerGetStateAction; export type AllowedActions = + | AccountsControllerGetSelectedAccountAction + | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkConfigurationByNetworkClientId + | NetworkControllerGetProviderConfigAction | GetTokenListState | KeyringControllerGetStateAction | PreferencesControllerGetStateAction @@ -182,7 +196,7 @@ export class TokenDetectionController extends StaticIntervalPollingController< */ constructor({ networkClientId, - selectedAddress = '', + selectedAddress, interval = DEFAULT_INTERVAL, disabled = true, getBalancesInSingleCall, @@ -216,8 +230,13 @@ export class TokenDetectionController extends StaticIntervalPollingController< this.setIntervalLength(interval); this.#networkClientId = networkClientId; - this.#selectedAddress = selectedAddress; - this.#chainId = this.#getCorrectChainId(networkClientId); + this.#selectedAddress = + selectedAddress ?? + this.messagingSystem.call('AccountsController:getSelectedAccount') + .address; + const { chainId } = + this.#getCorrectChainIdAndNetworkClientId(networkClientId); + this.#chainId = chainId; const { useTokenDetection: defaultUseTokenDetection } = this.messagingSystem.call('PreferencesController:getState'); @@ -308,7 +327,9 @@ export class TokenDetectionController extends StaticIntervalPollingController< const isNetworkClientIdChanged = this.#networkClientId !== selectedNetworkClientId; - const newChainId = this.#getCorrectChainId(selectedNetworkClientId); + const { chainId: newChainId } = + this.#getCorrectChainIdAndNetworkClientId(selectedNetworkClientId); + this.#chainId = newChainId; this.#isDetectionEnabledForNetwork = isTokenDetectionSupportedForNetwork(newChainId); @@ -381,13 +402,33 @@ export class TokenDetectionController extends StaticIntervalPollingController< }, this.getIntervalLength()); } - #getCorrectChainId(networkClientId?: NetworkClientId) { - const { chainId } = - this.messagingSystem.call( + #getCorrectChainIdAndNetworkClientId(networkClientId?: NetworkClientId): { + chainId: Hex; + networkClientId: NetworkClientId; + } { + if (networkClientId) { + const networkConfiguration = this.messagingSystem.call( 'NetworkController:getNetworkConfigurationByNetworkClientId', - networkClientId ?? this.#networkClientId, - ) ?? {}; - return chainId ?? this.#chainId; + networkClientId, + ); + if (networkConfiguration) { + return { + chainId: networkConfiguration.chainId, + networkClientId, + }; + } + } + const { chainId } = this.messagingSystem.call( + 'NetworkController:getProviderConfig', + ); + const newNetworkClientId = this.messagingSystem.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + return { + chainId, + networkClientId: newNetworkClientId, + }; } async _executePoll( @@ -399,7 +440,7 @@ export class TokenDetectionController extends StaticIntervalPollingController< } await this.detectTokens({ networkClientId, - accountAddress: options.address, + selectedAddress: options.address, }); } @@ -417,7 +458,7 @@ export class TokenDetectionController extends StaticIntervalPollingController< }: { selectedAddress?: string; networkClientId?: string } = {}) { await this.detectTokens({ networkClientId, - accountAddress: selectedAddress, + selectedAddress, }); this.setIntervalLength(DEFAULT_INTERVAL); } @@ -428,109 +469,110 @@ export class TokenDetectionController extends StaticIntervalPollingController< * * @param options - Options for token detection. * @param options.networkClientId - The ID of the network client to use. - * @param options.accountAddress - the selectedAddress against which to detect for token balances. + * @param options.selectedAddress - the selectedAddress against which to detect for token balances. */ async detectTokens({ networkClientId, - accountAddress, + selectedAddress, }: { networkClientId?: NetworkClientId; - accountAddress?: string; + selectedAddress?: string; } = {}): Promise { - if (!this.isActive || !this.#isDetectionEnabledForNetwork) { + if (!this.isActive) { + return; + } + + const addressAgainstWhichToDetect = + selectedAddress ?? this.#selectedAddress; + const { + chainId: chainIdAgainstWhichToDetect, + networkClientId: networkClientIdAgainstWhichToDetect, + } = this.#getCorrectChainIdAndNetworkClientId(networkClientId); + + if (!isTokenDetectionSupportedForNetwork(chainIdAgainstWhichToDetect)) { return; } - const selectedAddress = accountAddress ?? this.#selectedAddress; - const chainId = this.#getCorrectChainId(networkClientId); if ( !this.#isDetectionEnabledFromPreferences && - chainId !== ChainId.mainnet + chainIdAgainstWhichToDetect !== ChainId.mainnet ) { return; } const isTokenDetectionInactiveInMainnet = - !this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet; - const { tokenList } = this.messagingSystem.call( + !this.#isDetectionEnabledFromPreferences && + chainIdAgainstWhichToDetect === ChainId.mainnet; + const { tokensChainsCache } = this.messagingSystem.call( 'TokenListController:getState', ); + const tokenList = + tokensChainsCache[chainIdAgainstWhichToDetect]?.data ?? {}; const tokenListUsed = isTokenDetectionInactiveInMainnet ? STATIC_MAINNET_TOKEN_LIST : tokenList; - const { tokens, detectedTokens, ignoredTokens } = this.messagingSystem.call( - 'TokensController:getState', + const { allTokens, allDetectedTokens, allIgnoredTokens } = + this.messagingSystem.call('TokensController:getState'); + const [tokensAddresses, detectedTokensAddresses, ignoredTokensAddresses] = [ + allTokens, + allDetectedTokens, + allIgnoredTokens, + ].map((tokens) => + ( + tokens[chainIdAgainstWhichToDetect]?.[addressAgainstWhichToDetect] ?? [] + ).map((value) => (typeof value === 'string' ? value : value.address)), ); const tokensToDetect: string[] = []; for (const tokenAddress of Object.keys(tokenListUsed)) { if ( - !findCaseInsensitiveMatch( - tokens.map(({ address }) => address), - tokenAddress, - ) && - !findCaseInsensitiveMatch( - detectedTokens.map(({ address }) => address), - tokenAddress, + [ + 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( + const slicesOfTokensToDetect = []; + slicesOfTokensToDetect[0] = tokensToDetect.slice(0, 1000); + slicesOfTokensToDetect[1] = tokensToDetect.slice( 1000, tokensToDetect.length - 1, ); - - /* istanbul ignore else */ - if (!selectedAddress) { - return; - } - - for (const tokensSlice of sliceOfTokensToDetect) { + for (const tokensSlice of slicesOfTokensToDetect) { if (tokensSlice.length === 0) { break; } await safelyExecute(async () => { const balances = await this.#getBalancesInSingleCall( - selectedAddress, + addressAgainstWhichToDetect, tokensSlice, + networkClientIdAgainstWhichToDetect, ); - const tokensToAdd: Token[] = []; + const tokensWithBalance: Token[] = []; const eventTokensDetails: string[] = []; - let ignored; - for (const tokenAddress of Object.keys(balances)) { - if (ignoredTokens.length) { - ignored = ignoredTokens.find( - (ignoredTokenAddress) => - ignoredTokenAddress === toChecksumHexAddress(tokenAddress), - ); - } - const caseInsensitiveTokenKey = - findCaseInsensitiveMatch( - Object.keys(tokenListUsed), - tokenAddress, - ) ?? ''; - - if (ignored === undefined) { - const { decimals, symbol, aggregators, iconUrl, name } = - tokenListUsed[caseInsensitiveTokenKey]; - eventTokensDetails.push(`${symbol} - ${tokenAddress}`); - tokensToAdd.push({ - address: tokenAddress, - decimals, - symbol, - aggregators, - image: iconUrl, - isERC721: false, - name, - }); - } + for (const nonZeroTokenAddress of Object.keys(balances)) { + const { decimals, symbol, aggregators, iconUrl, name } = + tokenListUsed[nonZeroTokenAddress]; + eventTokensDetails.push(`${symbol} - ${nonZeroTokenAddress}`); + tokensWithBalance.push({ + address: nonZeroTokenAddress, + decimals, + symbol, + aggregators, + image: iconUrl, + isERC721: false, + name, + }); } - - if (tokensToAdd.length) { + if (tokensWithBalance.length) { this.#trackMetaMetricsEvent({ event: 'Token Detected', category: 'Wallet', @@ -542,10 +584,10 @@ export class TokenDetectionController extends StaticIntervalPollingController< }); await this.messagingSystem.call( 'TokensController:addDetectedTokens', - tokensToAdd, + tokensWithBalance, { - selectedAddress, - chainId, + selectedAddress: addressAgainstWhichToDetect, + chainId: chainIdAgainstWhichToDetect, }, ); } diff --git a/packages/assets-controllers/src/TokenRatesController.test.ts b/packages/assets-controllers/src/TokenRatesController.test.ts index d77143a607..a82a195ff7 100644 --- a/packages/assets-controllers/src/TokenRatesController.test.ts +++ b/packages/assets-controllers/src/TokenRatesController.test.ts @@ -497,7 +497,7 @@ describe('TokenRatesController', () => { it('should not update exchange rates if none of the addresses in "all tokens" or "all detected tokens" change, when normalized to checksum addresses', async () => { const chainId = '0xC'; - const selectedAddress = '0xA'; + const selectedAddress = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; await withController( { options: { @@ -510,7 +510,7 @@ describe('TokenRatesController', () => { [chainId]: { [selectedAddress]: [ { - address: '0xE2', + address: '0x0EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE2', decimals: 3, symbol: '', aggregators: [], @@ -533,7 +533,7 @@ describe('TokenRatesController', () => { [chainId]: { [selectedAddress]: [ { - address: '0xe2', + address: '0x0eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee2', decimals: 7, symbol: '', aggregators: [], diff --git a/packages/assets-controllers/src/assetsUtil.test.ts b/packages/assets-controllers/src/assetsUtil.test.ts index 4980d68593..22b7e8536c 100644 --- a/packages/assets-controllers/src/assetsUtil.test.ts +++ b/packages/assets-controllers/src/assetsUtil.test.ts @@ -176,9 +176,9 @@ describe('assetsUtil', () => { ).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, ); }); diff --git a/packages/assets-controllers/src/assetsUtil.ts b/packages/assets-controllers/src/assetsUtil.ts index e1c8af400e..f76b13ba2e 100644 --- a/packages/assets-controllers/src/assetsUtil.ts +++ b/packages/assets-controllers/src/assetsUtil.ts @@ -1,11 +1,11 @@ import type { BigNumber } from '@ethersproject/bignumber'; import { convertHexToDecimal, - GANACHE_CHAIN_ID, toChecksumHexAddress, } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; -import { BN, stripHexPrefix } from 'ethereumjs-util'; +import { remove0x } from '@metamask/utils'; +import BN from 'bn.js'; import { CID } from 'multiformats/cid'; import type { @@ -152,9 +152,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); } /** @@ -243,7 +241,7 @@ 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'); } /** diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts index 23082bd9be..9ebeca352e 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -356,7 +356,9 @@ export class CodefiTokenPricesServiceV2 const pricesByCurrencyByTokenAddress: SpotPricesEndpointData< Lowercase, Lowercase - > = await this.#tokenPricePolicy.execute(() => handleFetch(url)); + > = await this.#tokenPricePolicy.execute(() => + handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), + ); return tokenAddresses.reduce( ( diff --git a/packages/base-controller/src/BaseControllerV1.ts b/packages/base-controller/src/BaseControllerV1.ts index 30efb44569..c075684fa8 100644 --- a/packages/base-controller/src/BaseControllerV1.ts +++ b/packages/base-controller/src/BaseControllerV1.ts @@ -42,12 +42,12 @@ export class BaseControllerV1 { /** * Default options used to configure this controller */ - defaultConfig: C = {} as C; + defaultConfig: C = {} as never; /** * Default state set on this controller */ - defaultState: S = {} as S; + defaultState: S = {} as never; /** * Determines if listeners are notified of state changes @@ -59,9 +59,9 @@ export class BaseControllerV1 { */ name = 'BaseController'; - private readonly initialConfig: C; + private readonly initialConfig: Partial; - private readonly initialState: S; + private readonly initialState: Partial; private internalConfig: C = this.defaultConfig; @@ -76,10 +76,9 @@ export class BaseControllerV1 { * @param config - Initial options used to configure this controller. * @param state - Initial state to set on this controller. */ - 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; + constructor(config: Partial = {}, state: Partial = {}) { + this.initialState = state; + this.initialConfig = config; } /** @@ -128,21 +127,19 @@ export class BaseControllerV1 { ? (config as C) : Object.assign(this.internalConfig, config); - for (const [key, value] of Object.entries(this.internalConfig)) { + for (const key of Object.keys(this.internalConfig) as (keyof C)[]) { + const value = this.internalConfig[key]; if (value !== undefined) { - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this as any)[key] = value; + (this as unknown as C)[key] = value; } } } else { for (const key of Object.keys(config) as (keyof C)[]) { /* istanbul ignore else */ - if (typeof this.internalConfig[key] !== 'undefined') { - this.internalConfig[key] = (config as C)[key]; - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this as any)[key] = config[key]; + if (this.internalConfig[key] !== undefined) { + const value = (config as C)[key]; + this.internalConfig[key] = value; + (this as unknown as C)[key] = value; } } } diff --git a/packages/composable-controller/CHANGELOG.md b/packages/composable-controller/CHANGELOG.md index 076f944ad4..c7b04a9e26 100644 --- a/packages/composable-controller/CHANGELOG.md +++ b/packages/composable-controller/CHANGELOG.md @@ -7,9 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add and export functions `isBaseControllerV1` and `isBaseController`, which are type guards for validating controller instances ([#3904](https://github.com/MetaMask/core/pull/3904)) +- Add and export types `BaseControllerV1Instance`, `BaseControllerV2Instance`, `ControllerInstance` which are the narrowest supertypes for all controllers extending from, respectively, `BaseControllerV1`, `BaseController`, and both ([#3904](https://github.com/MetaMask/core/pull/3904)) + ### Changed - **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- **BREAKING:** Passing a non-controller into `controllers` constructor option now throws an error ([#3904](https://github.com/MetaMask/core/pull/3904)) +- **BREAKING:** The `AllowedActions` 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)) +- Add `@metamask/utils` ^8.3.0 as a dependency. ([#3904](https://github.com/MetaMask/core/pull/3904)) + +### Removed + +- **BREAKING:** Remove `ControllerList` as an exported type. ([#3904](https://github.com/MetaMask/core/pull/3904)) ## [5.0.1] diff --git a/packages/composable-controller/package.json b/packages/composable-controller/package.json index ebcb6ae1c6..a59203c241 100644 --- a/packages/composable-controller/package.json +++ b/packages/composable-controller/package.json @@ -31,10 +31,12 @@ "test:watch": "jest --watch" }, "dependencies": { - "@metamask/base-controller": "^4.1.1" + "@metamask/base-controller": "^4.1.1", + "@metamask/utils": "^8.3.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", + "@metamask/json-rpc-engine": "^7.3.2", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "immer": "^9.0.6", diff --git a/packages/composable-controller/src/ComposableController.test.ts b/packages/composable-controller/src/ComposableController.test.ts index 4e5d944fa1..868605deb1 100644 --- a/packages/composable-controller/src/ComposableController.test.ts +++ b/packages/composable-controller/src/ComposableController.test.ts @@ -7,10 +7,11 @@ import { BaseControllerV1, ControllerMessenger, } from '@metamask/base-controller'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { Patch } from 'immer'; import * as sinon from 'sinon'; -import type { ComposableControllerStateChangeEvent } from './ComposableController'; +import type { ComposableControllerEvents } from './ComposableController'; import { ComposableController } from './ComposableController'; // Mock BaseController classes @@ -106,7 +107,10 @@ describe('ComposableController', () => { describe('BaseControllerV1', () => { it('should compose controller state', () => { - const composableMessenger = new ControllerMessenger().getRestricted({ + const composableMessenger = new ControllerMessenger< + never, + ComposableControllerEvents + >().getRestricted({ name: 'ComposableController', }); const controller = new ComposableController({ @@ -123,7 +127,7 @@ describe('ComposableController', () => { it('should notify listeners of nested state change', () => { const controllerMessenger = new ControllerMessenger< never, - ComposableControllerStateChangeEvent + ComposableControllerEvents >(); const composableMessenger = controllerMessenger.getRestricted({ name: 'ComposableController', @@ -176,7 +180,7 @@ describe('ComposableController', () => { it('should notify listeners of nested state change', () => { const controllerMessenger = new ControllerMessenger< never, - ComposableControllerStateChangeEvent | FooControllerEvent + ComposableControllerEvents | FooControllerEvent >(); const fooControllerMessenger = controllerMessenger.getRestricted< 'FooController', @@ -240,7 +244,7 @@ describe('ComposableController', () => { const barController = new BarController(); const controllerMessenger = new ControllerMessenger< never, - ComposableControllerStateChangeEvent | FooControllerEvent + ComposableControllerEvents | FooControllerEvent >(); const fooControllerMessenger = controllerMessenger.getRestricted< 'FooController', @@ -280,7 +284,7 @@ describe('ComposableController', () => { const barController = new BarController(); const controllerMessenger = new ControllerMessenger< never, - ComposableControllerStateChangeEvent | FooControllerEvent + ComposableControllerEvents | FooControllerEvent >(); const fooControllerMessenger = controllerMessenger.getRestricted< 'FooController', @@ -335,5 +339,35 @@ describe('ComposableController', () => { }), ).toThrow('Messaging system is required'); }); + + it('should throw if composing a controller that does not extend from BaseController', () => { + const notController = new JsonRpcEngine(); + const controllerMessenger = new ControllerMessenger< + never, + FooControllerEvent + >(); + const fooControllerMessenger = controllerMessenger.getRestricted< + 'FooController', + never, + never + >({ + name: 'FooController', + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = controllerMessenger.getRestricted({ + name: 'ComposableController', + allowedEvents: ['FooController:stateChange'], + }); + expect( + () => + new ComposableController({ + // @ts-expect-error - Suppressing type error to test for runtime error handling + controllers: [notController, fooController], + messenger: composableControllerMessenger, + }), + ).toThrow( + 'Invalid controller: controller must extend from BaseController or BaseControllerV1', + ); + }); }); }); diff --git a/packages/composable-controller/src/ComposableController.ts b/packages/composable-controller/src/ComposableController.ts index 6d4f6eb7d8..fc3fd2565b 100644 --- a/packages/composable-controller/src/ComposableController.ts +++ b/packages/composable-controller/src/ComposableController.ts @@ -2,57 +2,114 @@ import { BaseController, BaseControllerV1 } from '@metamask/base-controller'; import type { ControllerStateChangeEvent, RestrictedControllerMessenger, + BaseState, + BaseConfig, + StateMetadata, } from '@metamask/base-controller'; +import { isValidJson, type Json } from '@metamask/utils'; export const controllerName = 'ComposableController'; -/* - * This type encompasses controllers based on either BaseControllerV1 or - * BaseController. The BaseController 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 BaseController properties - * that we use in the ComposableController (name and state). +// TODO: Remove this type once `BaseControllerV2` migrations are completed for all controllers. +/** + * A type encompassing all controller instances that extend from `BaseControllerV1`. */ -type ControllerInstance = - // TODO: Replace `any` with type +export type BaseControllerV1Instance = + // `any` is used to include all `BaseControllerV1` instances. // eslint-disable-next-line @typescript-eslint/no-explicit-any - BaseControllerV1 | { name: string; state: Record }; + BaseControllerV1; /** - * List of child controller instances + * A type encompassing all controller instances that extend from `BaseController` (formerly `BaseControllerV2`). + * + * The `BaseController` class itself can't be used directly as a type representing all of its subclasses, + * because the generic parameters it expects require knowing the exact shape of the controller's state and messenger. + * + * Instead, we look for an object with the `BaseController` properties that we use in the ComposableController (name and state). */ -export type ControllerList = ControllerInstance[]; +export type BaseControllerV2Instance = { + name: string; + state: Record; +}; + +// TODO: Remove `BaseControllerV1Instance` member once `BaseControllerV2` migrations are completed for all controllers. +/** + * A type encompassing all controller instances that extend from `BaseControllerV1` or `BaseController`. + */ +export type ControllerInstance = + | BaseControllerV1Instance + | BaseControllerV2Instance; /** * Determines if the given controller is an instance of BaseControllerV1 * @param controller - Controller instance to check * @returns True if the controller is an instance of BaseControllerV1 + * TODO: Deprecate once `BaseControllerV2` migrations are completed for all controllers. */ -function isBaseControllerV1( +export function isBaseControllerV1( controller: ControllerInstance, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): controller is BaseControllerV1 { - return controller instanceof BaseControllerV1; +): controller is BaseControllerV1< + BaseConfig & Record, + BaseState & Record +> { + return ( + 'name' in controller && + typeof controller.name === 'string' && + 'defaultConfig' in controller && + typeof controller.defaultConfig === 'object' && + 'defaultState' in controller && + typeof controller.defaultState === 'object' && + 'disabled' in controller && + typeof controller.disabled === 'boolean' && + controller instanceof BaseControllerV1 + ); +} + +/** + * 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 + */ +export function isBaseController( + controller: ControllerInstance, +): controller is BaseController { + return ( + 'name' in controller && + typeof controller.name === 'string' && + 'state' in controller && + typeof controller.state === 'object' && + controller instanceof BaseController + ); } export type ComposableControllerState = { - [name: string]: ControllerInstance['state']; + // `any` is used here to disable the `BaseController` type constraint which expects state properties to extend `Record`. + // `ComposableController` state needs to accommodate `BaseControllerV1` state objects that may have properties wider than `Json`. + // TODO: Replace `any` with `Json` once `BaseControllerV2` migrations are completed for all controllers. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [name: string]: Record; }; export type ComposableControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, - ComposableControllerState + Record >; export type ComposableControllerEvents = ComposableControllerStateChangeEvent; +type AnyControllerStateChangeEvent = ControllerStateChangeEvent< + string, + Record +>; + +type AllowedEvents = AnyControllerStateChangeEvent; + export type ComposableControllerMessenger = RestrictedControllerMessenger< typeof controllerName, never, - ControllerStateChangeEvent>, - string, - string + ComposableControllerEvents | AllowedEvents, + never, + AllowedEvents['type'] >; /** @@ -63,8 +120,6 @@ export class ComposableController extends BaseController< ComposableControllerState, ComposableControllerMessenger > { - readonly #controllers: ControllerList = []; - /** * Creates a ComposableController instance. * @@ -77,7 +132,7 @@ export class ComposableController extends BaseController< controllers, messenger, }: { - controllers: ControllerList; + controllers: ControllerInstance[]; messenger: ComposableControllerMessenger; }) { if (messenger === undefined) { @@ -86,23 +141,33 @@ export class ComposableController extends BaseController< super({ name: controllerName, - metadata: {}, - state: controllers.reduce((state, controller) => { - return { ...state, [controller.name]: controller.state }; - }, {} as ComposableControllerState), + metadata: controllers.reduce>( + (metadata, controller) => ({ + ...metadata, + [controller.name]: isBaseController(controller) + ? controller.metadata + : { persist: true, anonymous: true }, + }), + {}, + ), + state: controllers.reduce( + (state, controller) => { + return { ...state, [controller.name]: controller.state }; + }, + {}, + ), messenger, }); - this.#controllers = controllers; - this.#controllers.forEach((controller) => + controllers.forEach((controller) => this.#updateChildController(controller), ); } /** - * Adds a child controller instance to composable controller state, - * or updates the state of a child controller. + * Constructor helper that subscribes to child controller state changes. * @param controller - Controller instance to update + * TODO: Remove `isBaseControllerV1` branch once `BaseControllerV2` migrations are completed for all controllers. */ #updateChildController(controller: ControllerInstance): void { const { name } = controller; @@ -113,15 +178,18 @@ export class ComposableController extends BaseController< [name]: childState, })); }); - } else { - this.messagingSystem.subscribe( - `${String(name)}:stateChange`, - (childState: Record) => { + } else if (isBaseController(controller)) { + this.messagingSystem.subscribe(`${name}:stateChange`, (childState) => { + if (isValidJson(childState)) { this.update((state) => ({ ...state, [name]: childState, })); - }, + } + }); + } else { + throw new Error( + 'Invalid controller: controller must extend from BaseController or BaseControllerV1', ); } } diff --git a/packages/composable-controller/src/index.ts b/packages/composable-controller/src/index.ts index da2d27e177..c803f27d44 100644 --- a/packages/composable-controller/src/index.ts +++ b/packages/composable-controller/src/index.ts @@ -1,8 +1,14 @@ export type { - ControllerList, + BaseControllerV1Instance, + BaseControllerV2Instance, + ControllerInstance, ComposableControllerState, ComposableControllerStateChangeEvent, ComposableControllerEvents, ComposableControllerMessenger, } from './ComposableController'; -export { ComposableController } from './ComposableController'; +export { + ComposableController, + isBaseController, + isBaseControllerV1, +} from './ComposableController'; diff --git a/packages/composable-controller/tsconfig.json b/packages/composable-controller/tsconfig.json index f2d7b67ff6..cc814f313b 100644 --- a/packages/composable-controller/tsconfig.json +++ b/packages/composable-controller/tsconfig.json @@ -6,6 +6,9 @@ "references": [ { "path": "../base-controller" + }, + { + "path": "../json-rpc-engine" } ], "include": ["../../types", "./src"] diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 5cae021c89..f19ceb5687 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +## [8.0.3] + +### Changed + +- Bump `@metamask/ethjs-unit` to `^0.3.0` ([#3897](https://github.com/MetaMask/core/pull/3897)) + ## [8.0.2] ### Changed @@ -268,7 +274,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@8.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@8.0.3...HEAD +[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 diff --git a/packages/controller-utils/jest.config.js b/packages/controller-utils/jest.config.js index c469db2238..e77cd27868 100644 --- a/packages/controller-utils/jest.config.js +++ b/packages/controller-utils/jest.config.js @@ -25,5 +25,5 @@ module.exports = merge(baseConfig, { }, // 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 0000000000..46d6702311 --- /dev/null +++ b/packages/controller-utils/jest.environment.js @@ -0,0 +1,18 @@ +/* eslint-disable */ +const JSDOMEnvironment = require('jest-environment-jsdom'); + +// Custom test environment copied from https://github.com/jsdom/jsdom/issues/2524 +// in order to add TextEncoder to jsdom. TextEncoder is expected by @noble/hashes. + +module.exports = class CustomTestEnvironment extends JSDOMEnvironment { + async setup() { + await super.setup(); + if (typeof this.global.TextEncoder === 'undefined') { + 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 40dc855ba1..136d745706 100644 --- a/packages/controller-utils/package.json +++ b/packages/controller-utils/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/controller-utils", - "version": "8.0.2", + "version": "8.0.3", "description": "Data and convenience functions shared by multiple packages", "keywords": [ "MetaMask", @@ -31,12 +31,12 @@ "test:watch": "jest --watch" }, "dependencies": { + "@ethereumjs/util": "^8.1.0", "@metamask/eth-query": "^4.0.0", "@metamask/ethjs-unit": "^0.3.0", "@metamask/utils": "^8.3.0", "@spruceid/siwe-parser": "1.1.3", "eth-ens-namehash": "^2.0.8", - "ethereumjs-util": "^7.0.10", "fast-deep-equal": "^3.1.3" }, "devDependencies": { diff --git a/packages/controller-utils/src/siwe.ts b/packages/controller-utils/src/siwe.ts index 4c334688b4..cc92480942 100644 --- a/packages/controller-utils/src/siwe.ts +++ b/packages/controller-utils/src/siwe.ts @@ -1,5 +1,5 @@ +import { remove0x } from '@metamask/utils'; import { ParsedMessage } from '@spruceid/siwe-parser'; -import { isHexPrefixed } from 'ethereumjs-util'; import { projectLogger, createModuleLogger } from './logger'; @@ -7,15 +7,16 @@ 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) { if (typeof str !== 'string') { return str; } - return isHexPrefixed(str) ? str.slice(2) : str; + return remove0x(str); } /** @@ -26,7 +27,7 @@ function stripHexPrefix(str: string) { */ function msgHexToText(hex: string): string { try { - const stripped = stripHexPrefix(hex); + const stripped = safeStripHexPrefix(hex); const buff = Buffer.from(stripped, 'hex'); return buff.length === 32 ? hex : buff.toString('utf8'); } catch (e) { diff --git a/packages/controller-utils/src/util.test.ts b/packages/controller-utils/src/util.test.ts index 14f32b27dd..bbf993af5c 100644 --- a/packages/controller-utils/src/util.test.ts +++ b/packages/controller-utils/src/util.test.ts @@ -1,5 +1,5 @@ import EthQuery from '@metamask/eth-query'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import nock from 'nock'; import { FakeProvider } from '../../../tests/fake-provider'; diff --git a/packages/controller-utils/src/util.ts b/packages/controller-utils/src/util.ts index 6abc538a2c..2721befb3d 100644 --- a/packages/controller-utils/src/util.ts +++ b/packages/controller-utils/src/util.ts @@ -1,16 +1,15 @@ +import { isValidAddress, toChecksumAddress } from '@ethereumjs/util'; 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'; + remove0x, +} from '@metamask/utils'; +import BN from 'bn.js'; +import ensNamehash from 'eth-ens-namehash'; import deepEqual from 'fast-deep-equal'; import { MAX_SAFE_CHAIN_ID } from './constants'; @@ -29,7 +28,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 && @@ -45,7 +47,7 @@ export function isSafeChainId(chainId: Hex): boolean { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any export function BNToHex(inputBn: any) { - return addHexPrefix(inputBn.toString(16)); + return add0x(inputBn.toString(16)); } /** @@ -111,7 +113,7 @@ export function gweiDecToWEIBN(n: number | string) { * @returns The value in dec gwei as string. */ export function weiHexToGweiDec(hex: string) { - const hexWei = new BN(stripHexPrefix(hex), 16); + const hexWei = new BN(remove0x(hex), 16); return fromWei(hexWei, 'gwei'); } @@ -147,7 +149,7 @@ export function getBuyURL( * @returns A BN instance. */ export function hexToBN(inputHex: string) { - return inputHex ? new BN(stripHexPrefix(inputHex), 16) : new BN(0); + return inputHex ? new BN(remove0x(inputHex), 16) : new BN(0); } /** @@ -158,7 +160,7 @@ export function hexToBN(inputHex: string) { */ export function hexToText(hex: string) { try { - const stripped = stripHexPrefix(hex); + const stripped = remove0x(hex); const buff = Buffer.from(stripped, 'hex'); return buff.toString('utf8'); } catch (e) { @@ -256,10 +258,10 @@ export async function safelyExecuteWithTimeout( * Convert an address to a checksummed hexidecimal address. * * @param address - The address to convert. - * @returns A 0x-prefixed hexidecimal checksummed address. + * @returns A 0x-prefixed hexidecimal checksummed address, if address is valid. Otherwise original input 0x-prefixe, if address is valid. Otherwise original input 0x-prefixed. */ export function toChecksumHexAddress(address: string) { - const hexPrefixed = addHexPrefix(address); + const hexPrefixed = add0x(address); if (!isHexString(hexPrefixed)) { // Version 5.1 of ethereumjs-utils would have returned '0xY' for input 'y' // but we shouldn't waste effort trying to change case on a clearly invalid @@ -272,9 +274,9 @@ export function toChecksumHexAddress(address: string) { /** * 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`. + * wrapper around @metamask/utils.isValidHexAddress, with the exception that it + * by default will return true for hex strings that are otherwise valid + * hex addresses, but are not prefixed with `0x`. * * @param possibleAddress - Input parameter to check against. * @param options - The validation options. @@ -284,11 +286,11 @@ export function toChecksumHexAddress(address: string) { export function isValidHexAddress( possibleAddress: string, { allowNonPrefixed = true } = {}, -) { +): boolean { const addressToCheck = allowNonPrefixed - ? addHexPrefix(possibleAddress) + ? add0x(possibleAddress) : possibleAddress; - if (!isHexString(addressToCheck)) { + if (!isStrictHexString(addressToCheck)) { return false; } @@ -480,7 +482,7 @@ export function query( export const convertHexToDecimal = ( value: string | undefined = '0x0', ): number => { - if (isHexString(value)) { + if (isStrictHexString(value)) { return parseInt(value, 16); } diff --git a/packages/ens-controller/package.json b/packages/ens-controller/package.json index 6073e85780..c7b915c30f 100644 --- a/packages/ens-controller/package.json +++ b/packages/ens-controller/package.json @@ -33,7 +33,7 @@ "dependencies": { "@ethersproject/providers": "^5.7.0", "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/utils": "^8.3.0", "ethereum-ens-network-map": "^1.0.2", "punycode": "^2.1.1" diff --git a/packages/gas-fee-controller/CHANGELOG.md b/packages/gas-fee-controller/CHANGELOG.md index 712524455e..0b8d283528 100644 --- a/packages/gas-fee-controller/CHANGELOG.md +++ b/packages/gas-fee-controller/CHANGELOG.md @@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +## [13.0.1] + +### Changed + +- Bump `@metamask/ethjs-unit` to `^0.3.0` ([#3897](https://github.com/MetaMask/core/pull/3897)) +- Bump `@metamask/controller-utils` to `^8.0.3` ([#3915](https://github.com/MetaMask/core/pull/3915)) + ## [13.0.0] ### Changed @@ -205,7 +212,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@13.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@13.0.1...HEAD +[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 diff --git a/packages/gas-fee-controller/package.json b/packages/gas-fee-controller/package.json index b408798c4a..79f05865af 100644 --- a/packages/gas-fee-controller/package.json +++ b/packages/gas-fee-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/gas-fee-controller", - "version": "13.0.0", + "version": "13.0.1", "description": "Periodically calculates gas fee estimates based on various gas limits as well as other data displayed on transaction confirm screens", "keywords": [ "MetaMask", @@ -32,14 +32,15 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/eth-query": "^4.0.0", "@metamask/ethjs-unit": "^0.3.0", "@metamask/network-controller": "^17.2.0", "@metamask/polling-controller": "^5.0.0", "@metamask/utils": "^8.3.0", + "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", - "ethereumjs-util": "^7.0.10", + "bn.js": "^5.2.1", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/packages/gas-fee-controller/src/fetchBlockFeeHistory.test.ts b/packages/gas-fee-controller/src/fetchBlockFeeHistory.test.ts index 6a4d805e65..f661b15c3a 100644 --- a/packages/gas-fee-controller/src/fetchBlockFeeHistory.test.ts +++ b/packages/gas-fee-controller/src/fetchBlockFeeHistory.test.ts @@ -1,6 +1,6 @@ import { query, fromHex, toHex } from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import { when } from 'jest-when'; import { FakeProvider } from '../../../tests/fake-provider'; diff --git a/packages/gas-fee-controller/src/fetchBlockFeeHistory.ts b/packages/gas-fee-controller/src/fetchBlockFeeHistory.ts index 27304d2772..422ee977bc 100644 --- a/packages/gas-fee-controller/src/fetchBlockFeeHistory.ts +++ b/packages/gas-fee-controller/src/fetchBlockFeeHistory.ts @@ -1,5 +1,5 @@ import { query, fromHex, toHex } from '@metamask/controller-utils'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory.test.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory.test.ts index b514a0a9c7..2938ee4745 100644 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory.test.ts +++ b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory.test.ts @@ -1,5 +1,5 @@ import EthQuery from '@metamask/eth-query'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import { when } from 'jest-when'; import fetchBlockFeeHistory from './fetchBlockFeeHistory'; diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.test.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.test.ts index f39f368496..d8f9caedcf 100644 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.test.ts +++ b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.test.ts @@ -1,4 +1,4 @@ -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import calculateGasFeeEstimatesForPriorityLevels from './calculateGasFeeEstimatesForPriorityLevels'; diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.ts index 6ae1b0da08..0c869f81c3 100644 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.ts +++ b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.ts @@ -1,6 +1,6 @@ import { GWEI } from '@metamask/controller-utils'; import { fromWei } from '@metamask/ethjs-unit'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import type { FeeHistoryBlock } from '../fetchBlockFeeHistory'; import type { Eip1559GasFee, GasFeeEstimates } from '../GasFeeController'; diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/medianOf.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/medianOf.ts index c7dfdc2a6f..3946a615a8 100644 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/medianOf.ts +++ b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/medianOf.ts @@ -1,4 +1,4 @@ -import type { BN } from 'ethereumjs-util'; +import type * as BN from 'bn.js'; /** * Finds the median among a list of numbers. Note that this is different from the implementation diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/types.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/types.ts index 296700bd6d..87b8f9f776 100644 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/types.ts +++ b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/types.ts @@ -1,4 +1,4 @@ -import type { BN } from 'ethereumjs-util'; +import type * as BN from 'bn.js'; export type EthBlock = { number: BN; diff --git a/packages/gas-fee-controller/src/gas-util.ts b/packages/gas-fee-controller/src/gas-util.ts index 307b406642..17a242ac8b 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, diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 29b454e5b0..013103338c 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -42,12 +42,12 @@ "test:watch": "jest --watch" }, "dependencies": { - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^8.3.0" }, "devDependencies": { - "@lavamoat/allow-scripts": "^2.3.1", + "@lavamoat/allow-scripts": "^3.0.2", "@metamask/auto-changelog": "^3.4.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index e7533f5c07..825ea3298e 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +### 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)) + - Class methods `addNewAccount`, `addNewAccountForKeyring`, `createNewVaultAndRestore`, `createNewVaultAndKeychain`, `importAccountWithStrategy`, `restoreQRKeyring`, `unlockQRHardwareWalletAccount`, `forgetQRDevice` no longer directly updates `PreferencesController` state by calling the `updateIdentities` callback. + - Class method `submitPassword` no longer directly updates `PreferencesController` state by calling the `syncIdentities` callback. + - Class method `unlockQRHardwareWalletAccount` no longer directly updates `PreferencesController` state by calling the `setAccountLabel`, `setSelectedAddress` callbacks. + ## [12.2.0] ### Added diff --git a/packages/keyring-controller/jest.config.js b/packages/keyring-controller/jest.config.js index 14946730e8..5bb1e57987 100644 --- a/packages/keyring-controller/jest.config.js +++ b/packages/keyring-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 95.71, + branches: 95.65, functions: 100, - lines: 99.21, - statements: 99.22, + lines: 99.18, + statements: 99.18, }, }, diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index f4f68be49c..f6aebd35c9 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -31,6 +31,7 @@ "test:watch": "jest --watch" }, "dependencies": { + "@ethereumjs/util": "^8.1.0", "@keystonehq/metamask-airgapped-keyring": "^0.13.1", "@metamask/base-controller": "^4.1.1", "@metamask/browser-passworder": "^4.3.0", @@ -41,7 +42,6 @@ "@metamask/message-manager": "^7.3.8", "@metamask/utils": "^8.3.0", "async-mutex": "^0.2.6", - "ethereumjs-util": "^7.0.10", "ethereumjs-wallet": "^1.0.1", "immer": "^9.0.6" }, @@ -49,7 +49,7 @@ "@ethereumjs/common": "^3.2.0", "@ethereumjs/tx": "^4.2.0", "@keystonehq/bc-ur-registry-eth": "^0.9.0", - "@lavamoat/allow-scripts": "^2.3.1", + "@lavamoat/allow-scripts": "^3.0.2", "@metamask/auto-changelog": "^3.4.4", "@metamask/scure-bip39": "^2.1.1", "@types/jest": "^27.4.1", @@ -71,9 +71,6 @@ "registry": "https://registry.npmjs.org/" }, "lavamoat": { - "allowScripts": { - "ethereumjs-util>ethereum-cryptography>keccak": false, - "ethereumjs-util>ethereum-cryptography>secp256k1": false - } + "allowScripts": {} } } diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 9c5118b37e..5599dcacbd 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -15,12 +15,12 @@ import type { EthKeyring } from '@metamask/keyring-api'; import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; import type { KeyringClass } from '@metamask/utils'; import { + bytesToHex, 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'; @@ -43,6 +43,7 @@ import { AccountImportStrategy, KeyringController, KeyringTypes, + isCustodyKeyring, keyringBuilderFactory, } from './KeyringController'; @@ -84,10 +85,6 @@ describe('KeyringController', () => { new KeyringController({ messenger: buildKeyringControllerMessenger(), cacheEncryptionKey: true, - updateIdentities: jest.fn(), - setAccountLabel: jest.fn(), - syncIdentities: jest.fn(), - setSelectedAddress: jest.fn(), }), ).not.toThrow(); }); @@ -100,10 +97,6 @@ describe('KeyringController', () => { messenger: buildKeyringControllerMessenger(), cacheEncryptionKey: true, encryptor: { encrypt: jest.fn(), decrypt: jest.fn() }, - updateIdentities: jest.fn(), - setAccountLabel: jest.fn(), - syncIdentities: jest.fn(), - setSelectedAddress: jest.fn(), }), ).toThrow(KeyringControllerError.UnsupportedEncryptionKeyExport); }); @@ -112,57 +105,41 @@ describe('KeyringController', () => { 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 () => { @@ -204,32 +181,25 @@ describe('KeyringController', () => { 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 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], + ); + }); }); it('should not throw when `keyring.getAccounts()` returns a shallow copy', async () => { @@ -239,7 +209,7 @@ describe('KeyringController', () => { keyringBuilderFactory(MockShallowGetAccountsKeyring), ], }, - async ({ controller, initialState, preferences }) => { + async ({ controller }) => { const mockKeyring = (await controller.addNewKeyring( MockShallowGetAccountsKeyring.type, )) as Keyring; @@ -252,13 +222,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); }, ); }); @@ -266,32 +229,25 @@ 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 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], + ); + }); }); it('should throw an error if passed accountCount param is out of sequence', async () => { @@ -334,23 +290,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, - ); - }, - ); + await withController(async ({ controller, initialState }) => { + 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 + }); }); it('should throw error with no HD keyring', async () => { @@ -474,9 +423,8 @@ describe('KeyringController', () => { it('should create new vault, mnemonic and keychain', async () => { await withController( { cacheEncryptionKey }, - async ({ controller, initialState, preferences, encryptor }) => { + async ({ controller, initialState, encryptor }) => { const cleanKeyringController = new KeyringController({ - ...preferences, messenger: buildKeyringControllerMessenger(), cacheEncryptionKey, encryptor, @@ -959,12 +907,11 @@ describe('KeyringController', () => { }); 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); }); }); }); @@ -984,9 +931,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( @@ -1033,13 +978,12 @@ describe('KeyringController', () => { }); 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], ); - expect(preferences.setSelectedAddress.called).toBe(false); }); }); @@ -1272,7 +1216,7 @@ describe('KeyringController', () => { describe('when the keyring for the given address supports signPersonalMessage', () => { it('should sign personal message', async () => { await withController(async ({ controller, initialState }) => { - const data = bufferToHex(Buffer.from('Hello from test', 'utf8')); + const data = bytesToHex(Buffer.from('Hello from test', 'utf8')); const account = initialState.keyrings[0].accounts[0]; const signature = await controller.signPersonalMessage({ data, @@ -2343,7 +2287,7 @@ describe('KeyringController', () => { ), ); - const data = bufferToHex( + const data = bytesToHex( Buffer.from('Example `personal_sign` message', 'utf8'), ); const qrKeyring = signProcessKeyringController.state.keyrings.find( @@ -2791,6 +2735,20 @@ describe('KeyringController', () => { }); }); + 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 @@ -3102,18 +3060,11 @@ describe('KeyringController', () => { 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; @@ -3186,17 +3137,10 @@ 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 controller = new KeyringController({ encryptor, messenger, - ...preferences, ...rest, }); if (!rest.skipVaultCreation) { @@ -3204,7 +3148,6 @@ async function withController( } return await fn({ controller, - preferences, encryptor, initialState: controller.state, messenger, diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 2ead832949..654d508903 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -1,4 +1,5 @@ import type { TxData, TypedTransaction } from '@ethereumjs/tx'; +import { isValidPrivate, toBuffer, getBinarySize } from '@ethereumjs/util'; import type { MetaMaskKeyring as QRKeyring, IKeyringState as IQRKeyringState, @@ -27,7 +28,9 @@ import type { KeyringClass, } from '@metamask/utils'; import { + add0x, assertIsStrictHexString, + bytesToHex, hasProperty, isObject, isValidHexAddress, @@ -35,14 +38,6 @@ import { remove0x, } from '@metamask/utils'; import { Mutex } from 'async-mutex'; -import { - addHexPrefix, - bufferToHex, - isValidPrivate, - toBuffer, - stripHexPrefix, - getBinarySize, -} from 'ethereumjs-util'; import Wallet, { thirdparty as importers } from 'ethereumjs-wallet'; import type { Patch } from 'immer'; @@ -61,9 +56,18 @@ export enum KeyringTypes { ledger = 'Ledger Hardware', lattice = 'Lattice Hardware', snap = 'Snap Keyring', - custody = 'Custody - JSONRPC', } +/** + * Custody keyring types are a special case, as they are not a single type + * but they all start with the prefix "Custody". + * @param keyringType - The type of the keyring. + * @returns Whether the keyring type is a custody keyring. + */ +export const isCustodyKeyring = (keyringType: string): boolean => { + return keyringType.startsWith('Custody'); +}; + /** * @type KeyringControllerState * @@ -209,10 +213,6 @@ export type KeyringControllerMessenger = RestrictedControllerMessenger< >; export type KeyringControllerOptions = { - syncIdentities: (addresses: string[]) => string; - updateIdentities: (addresses: string[]) => void; - setSelectedAddress: (selectedAddress: string) => void; - setAccountLabel?: (address: string, label: string) => void; keyringBuilders?: { (): EthKeyring; type: string }[]; messenger: KeyringControllerMessenger; state?: { vault?: string }; @@ -486,14 +486,6 @@ export class KeyringController extends BaseController< > { private readonly mutex = new Mutex(); - private readonly syncIdentities: (addresses: string[]) => string; - - private readonly updateIdentities: (addresses: string[]) => void; - - private readonly setSelectedAddress: (selectedAddress: string) => void; - - private readonly setAccountLabel?: (address: string, label: string) => void; - #keyringBuilders: { (): EthKeyring; type: string }[]; #keyrings: EthKeyring[]; @@ -514,10 +506,6 @@ export class KeyringController extends BaseController< * Creates a KeyringController instance. * * @param options - Initial options used to configure this controller - * @param options.syncIdentities - Sync identities with the given list of addresses. - * @param options.updateIdentities - Generate an identity for each address given that doesn't already have an identity. - * @param options.setSelectedAddress - Set the selected address. - * @param options.setAccountLabel - Set a new name for account. * @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. @@ -526,10 +514,6 @@ export class KeyringController extends BaseController< */ constructor(options: KeyringControllerOptions) { const { - syncIdentities, - updateIdentities, - setSelectedAddress, - setAccountLabel, encryptor = encryptorUtils, keyringBuilders, messenger, @@ -567,11 +551,6 @@ export class KeyringController extends BaseController< assertIsExportableKeyEncryptor(encryptor); } - this.syncIdentities = syncIdentities; - this.updateIdentities = updateIdentities; - this.setSelectedAddress = setSelectedAddress; - this.setAccountLabel = setAccountLabel; - this.#registerMessageHandlers(); } @@ -612,8 +591,6 @@ export class KeyringController extends BaseController< ); await this.verifySeedPhrase(); - this.updateIdentities(await this.getAccounts()); - return { keyringState: this.#getMemState(), addedAccountAddress, @@ -652,8 +629,6 @@ export class KeyringController extends BaseController< ); assertIsStrictHexString(addedAccountAddress); - this.updateIdentities(await this.getAccounts()); - return addedAccountAddress; } @@ -694,7 +669,6 @@ export class KeyringController extends BaseController< } try { - this.updateIdentities([]); await this.#createNewVaultWithKeyring(password, { type: KeyringTypes.hd, opts: { @@ -702,7 +676,6 @@ export class KeyringController extends BaseController< numberOfAccounts: 1, }, }); - this.updateIdentities(await this.getAccounts()); return this.#getMemState(); } finally { releaseLock(); @@ -723,7 +696,6 @@ export class KeyringController extends BaseController< await this.#createNewVaultWithKeyring(password, { type: KeyringTypes.hd, }); - this.updateIdentities(await this.getAccounts()); } return this.#getMemState(); } finally { @@ -1050,7 +1022,7 @@ export class KeyringController extends BaseController< if (!importedKey) { throw new Error('Cannot import an empty key.'); } - const prefixed = addHexPrefix(importedKey); + const prefixed = add0x(importedKey); let bufferedPrivateKey; try { @@ -1067,7 +1039,7 @@ export class KeyringController extends BaseController< throw new Error('Cannot import invalid private key.'); } - privateKey = stripHexPrefix(prefixed); + privateKey = remove0x(prefixed); break; case 'json': let wallet; @@ -1077,7 +1049,7 @@ export class KeyringController extends BaseController< } catch (e) { wallet = wallet || (await Wallet.fromV3(input, password, true)); } - privateKey = bufferToHex(wallet.getPrivateKey()); + privateKey = bytesToHex(wallet.getPrivateKey()); break; default: throw new Error(`Unexpected import strategy: '${strategy}'`); @@ -1086,8 +1058,6 @@ export class KeyringController extends BaseController< privateKey, ])) as EthKeyring; const accounts = await newKeyring.getAccounts(); - const allAccounts = await this.getAccounts(); - this.updateIdentities(allAccounts); return { keyringState: this.#getMemState(), importedAccountAddress: accounts[0], @@ -1370,8 +1340,6 @@ export class KeyringController extends BaseController< this.#keyrings = await this.#unlockKeyrings(password); this.#setUnlocked(); - const accounts = await this.getAccounts(); - const qrKeyring = this.getQRKeyring(); if (qrKeyring) { // if there is a QR keyring, we need to subscribe @@ -1379,7 +1347,6 @@ export class KeyringController extends BaseController< this.#subscribeToQRKeyringEvents(qrKeyring); } - await this.syncIdentities(accounts); return this.#getMemState(); } @@ -1458,7 +1425,6 @@ export class KeyringController extends BaseController< async restoreQRKeyring(serialized: any): Promise { (await this.getOrAddQRKeyring()).deserialize(serialized); await this.persistAllKeyrings(); - this.updateIdentities(await this.getAccounts()); } async resetQRKeyringState(): Promise { @@ -1531,22 +1497,11 @@ export class KeyringController extends BaseController< const keyring = await this.getOrAddQRKeyring(); keyring.setAccountToUnlock(index); - const oldAccounts = await this.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.addNewAccountForKeyring(keyring as unknown as EthKeyring); - const newAccounts = await this.getAccounts(); - this.updateIdentities(newAccounts); - newAccounts.forEach((address: string) => { - if (!oldAccounts.includes(address)) { - if (this.setAccountLabel) { - this.setAccountLabel(address, `${keyring.getName()} ${index}`); - } - this.setSelectedAddress(address); - } - }); await this.persistAllKeyrings(); } @@ -1568,7 +1523,6 @@ export class KeyringController extends BaseController< const removedAccounts = allAccounts.filter( (address: string) => !remainingAccounts.includes(address), ); - this.updateIdentities(remainingAccounts); await this.persistAllKeyrings(); return { removedAccounts, remainingAccounts }; } diff --git a/packages/logging-controller/package.json b/packages/logging-controller/package.json index 0cbd6c0cd8..c123d31ad4 100644 --- a/packages/logging-controller/package.json +++ b/packages/logging-controller/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/packages/message-manager/package.json b/packages/message-manager/package.json index 6bda7d195f..adc5f80e04 100644 --- a/packages/message-manager/package.json +++ b/packages/message-manager/package.json @@ -32,11 +32,10 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/eth-sig-util": "^7.0.1", "@metamask/utils": "^8.3.0", "@types/uuid": "^8.3.0", - "ethereumjs-util": "^7.0.10", "jsonschema": "^1.2.4", "uuid": "^8.3.2" }, diff --git a/packages/message-manager/src/utils.ts b/packages/message-manager/src/utils.ts index 7de12abb08..39b1b4bcf2 100644 --- a/packages/message-manager/src/utils.ts +++ b/packages/message-manager/src/utils.ts @@ -4,7 +4,7 @@ import { typedSignatureHash, } from '@metamask/eth-sig-util'; import type { Hex } from '@metamask/utils'; -import { addHexPrefix, bufferToHex, stripHexPrefix } from 'ethereumjs-util'; +import { add0x, bytesToHex, remove0x } from '@metamask/utils'; import { validate } from 'jsonschema'; import type { DecryptMessageParams } from './DecryptMessageManager'; @@ -37,14 +37,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')); + return bytesToHex(Buffer.from(data, 'utf8')); } /** diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 252de6bc2f..9503b6a358 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -32,13 +32,13 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/eth-json-rpc-infura": "^9.0.0", "@metamask/eth-json-rpc-middleware": "^12.1.0", "@metamask/eth-json-rpc-provider": "^2.3.2", "@metamask/eth-query": "^4.0.0", "@metamask/json-rpc-engine": "^7.3.2", - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/swappable-obj-proxy": "^2.2.0", "@metamask/utils": "^8.3.0", "async-mutex": "^0.2.6", diff --git a/packages/permission-controller/CHANGELOG.md b/packages/permission-controller/CHANGELOG.md index 8c54505180..5a0ba49fee 100644 --- a/packages/permission-controller/CHANGELOG.md +++ b/packages/permission-controller/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +## [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 @@ -183,7 +189,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@8.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@8.0.1...HEAD +[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 diff --git a/packages/permission-controller/package.json b/packages/permission-controller/package.json index 4a685f2c53..e43b0bce9a 100644 --- a/packages/permission-controller/package.json +++ b/packages/permission-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/permission-controller", - "version": "8.0.0", + "version": "8.0.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", @@ -32,9 +32,9 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/json-rpc-engine": "^7.3.2", - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/utils": "^8.3.0", "@types/deep-freeze-strict": "^1.1.0", "deep-freeze-strict": "^1.1.1", diff --git a/packages/permission-log-controller/src/PermissionLogController.ts b/packages/permission-log-controller/src/PermissionLogController.ts index 731bf30237..d1938426b2 100644 --- a/packages/permission-log-controller/src/PermissionLogController.ts +++ b/packages/permission-log-controller/src/PermissionLogController.ts @@ -83,9 +83,6 @@ const defaultState: PermissionLogControllerState = { permissionActivityLog: [], }; -/** - * The name of the {@link PermissionController}. - */ const name = 'PermissionLogController'; /** diff --git a/packages/permission-log-controller/tests/PermissionLogController.test.ts b/packages/permission-log-controller/tests/PermissionLogController.test.ts index d1350bb2f5..e3464ffbbb 100644 --- a/packages/permission-log-controller/tests/PermissionLogController.test.ts +++ b/packages/permission-log-controller/tests/PermissionLogController.test.ts @@ -2,11 +2,9 @@ import { ControllerMessenger } from '@metamask/base-controller'; import type { JsonRpcEngineReturnHandler, JsonRpcEngineNextCallback, - JsonRpcMiddleware, } from '@metamask/json-rpc-engine'; import { type PendingJsonRpcResponse, - type JsonRpcParams, type Json, type JsonRpcRequest, PendingJsonRpcResponseStruct, @@ -16,21 +14,14 @@ import { nanoid } from 'nanoid'; import { LOG_LIMIT, LOG_METHOD_TYPES } from '../src/enums'; import { type Permission, - type JsonRpcRequestWithOrigin, - type PermissionActivityLog, + type PermissionLogControllerState, PermissionLogController, } from '../src/PermissionLogController'; import { constants, getters, noop } from './helpers'; const { PERMS, RPC_REQUESTS } = getters; -const { - ACCOUNTS, - EXPECTED_HISTORIES, - SUBJECTS, - PERM_NAMES, - REQUEST_IDS, - RESTRICTED_METHODS, -} = constants; +const { ACCOUNTS, EXPECTED_HISTORIES, SUBJECTS, PERM_NAMES, REQUEST_IDS } = + constants; class CustomError extends Error { code: number; @@ -43,40 +34,35 @@ class CustomError extends Error { const name = 'PermissionLogController'; -/** - * Constructs a restricted controller messenger. - * - * @returns A restricted controller messenger. - */ -function getMessenger() { - return new ControllerMessenger().getRestricted({ +const initController = ({ + restrictedMethods, + state, +}: { + restrictedMethods: Set; + state?: Partial; +}): PermissionLogController => { + const messenger = new ControllerMessenger().getRestricted< + typeof name, + never, + never + >({ name, }); -} - -const initPermissionLogController = (state = {}): PermissionLogController => { - const messenger = getMessenger(); return new PermissionLogController({ messenger, - restrictedMethods: RESTRICTED_METHODS, + restrictedMethods, state, }); }; -const mockNext: JsonRpcEngineNextCallback = (handler) => { - if (handler) { - handler(noop); - } -}; - -const initMiddleware = ( - controller: PermissionLogController, -): JsonRpcMiddleware => { - const middleware = controller.createMiddleware(); - return (req, res, next, end) => { - middleware(req, res, next, end); +const mockNext = + (advanceTime: boolean): JsonRpcEngineNextCallback => + (handler) => { + if (advanceTime) { + jest.advanceTimersByTime(1); + } + handler?.(noop); }; -}; const initClock = () => { jest.useFakeTimers('modern'); @@ -90,156 +76,153 @@ const tearDownClock = () => { const getSavedMockNext = ( arr: (JsonRpcEngineReturnHandler | undefined)[], + advanceTime: boolean, ): JsonRpcEngineNextCallback => (handler) => { + if (advanceTime) { + jest.advanceTimersByTime(1); + } arr.push(handler); }; -/** - * Validates an activity log entry with respect to a request, response, and - * relevant metadata. - * - * @param entry - The activity log entry to validate. - * @param req - The request that generated the entry. - * @param res - The response for the request, if any. - * @param methodType - The method log controller method type of the request. - * @param success - Whether the request succeeded or not. - */ -function validateActivityEntry( - entry: PermissionActivityLog, - req: JsonRpcRequestWithOrigin, - res: PendingJsonRpcResponse | null, - methodType: LOG_METHOD_TYPES, - success: boolean, -) { - expect(entry).toBeDefined(); - - expect(entry.id).toStrictEqual(req.id); - expect(entry.method).toStrictEqual(req.method); - expect(entry.origin).toStrictEqual(req.origin); - expect(entry.methodType).toStrictEqual(methodType); - - expect(Number.isInteger(entry.requestTime)).toBe(true); - if (res) { - expect(Number.isInteger(entry.responseTime)).toBe(true); - expect(entry.requestTime <= (entry.responseTime as number)).toBe(true); - expect(entry.success).toStrictEqual(success); - } else { - expect(entry.requestTime > 0).toBe(true); - expect(entry).toMatchObject({ - responseTime: null, - success: null, - }); - } -} - describe('PermissionLogController', () => { describe('createMiddleware', () => { describe('restricted method activity log', () => { - let controller: PermissionLogController; - let logMiddleware: JsonRpcMiddleware; - beforeEach(() => { - controller = initPermissionLogController(); - logMiddleware = initMiddleware(controller); + initClock(); }); - it('records activity for restricted methods', () => { - let req: JsonRpcRequestWithOrigin, res: PendingJsonRpcResponse; + afterAll(() => { + tearDownClock(); + }); - // test_method, success - req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); - req.id = REQUEST_IDS.a; - res = { + it('records activity for a successful restricted method request', () => { + const controller = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = controller.createMiddleware(); + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); + const res = { ...PendingJsonRpcResponseStruct.TYPE, result: ['bar'], }; - logMiddleware(req, res, mockNext, noop); + logMiddleware(req, res, mockNext(true), noop); - expect(controller.state.permissionActivityLog).toHaveLength(1); - const entry1 = controller.state.permissionActivityLog[0]; - validateActivityEntry( - entry1, - req, - res, - LOG_METHOD_TYPES.restricted, - true, - ); + 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, + }, + ]); + }); - // eth_accounts, failure - req = RPC_REQUESTS.eth_accounts(SUBJECTS.b.origin); - req.id = REQUEST_IDS.b; - res = { + it('records activity for a failed restricted method request', () => { + const controller = initController({ + restrictedMethods: new Set(['eth_accounts']), + }); + const logMiddleware = controller.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, noop); + logMiddleware(req, res, mockNext(true), noop); - expect(controller.state.permissionActivityLog).toHaveLength(2); - const entry2 = controller.state.permissionActivityLog[1]; - validateActivityEntry( - entry2, - req, - res, - LOG_METHOD_TYPES.restricted, - false, - ); + 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, + }, + ]); + }); - // eth_requestAccounts, success - req = RPC_REQUESTS.eth_requestAccounts(SUBJECTS.c.origin); - req.id = REQUEST_IDS.c; - res = { + it('records activity for a restricted method request with successful eth_requestAccounts', () => { + const controller = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.createMiddleware(); + const req = RPC_REQUESTS.eth_requestAccounts(SUBJECTS.c.origin); + const res = { ...PendingJsonRpcResponseStruct.TYPE, result: ACCOUNTS.c.permitted, }; - logMiddleware(req, res, mockNext, noop); + logMiddleware(req, res, mockNext(true), noop); - expect(controller.state.permissionActivityLog).toHaveLength(3); - const entry3 = controller.state.permissionActivityLog[2]; - validateActivityEntry( - entry3, - req, - res, - LOG_METHOD_TYPES.restricted, - true, - ); + 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, + }, + ]); + }); - // test_method, no response - req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); - req.id = REQUEST_IDS.a; + it('handles a restricted method request without a response', () => { + const controller = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = controller.createMiddleware(); + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); // @ts-expect-error We are intentionally passing bad input. - res = null; - - logMiddleware(req, res, mockNext, noop); - - expect(controller.state.permissionActivityLog).toHaveLength(4); - const entry4 = controller.state.permissionActivityLog[3]; - validateActivityEntry( - entry4, - { ...req }, - null, - LOG_METHOD_TYPES.restricted, - false, - ); + const res: PendingJsonRpcResponse = null; - // Validate final state - expect(entry1).toStrictEqual(controller.state.permissionActivityLog[0]); - expect(entry2).toStrictEqual(controller.state.permissionActivityLog[1]); - expect(entry3).toStrictEqual(controller.state.permissionActivityLog[2]); - expect(entry4).toStrictEqual(controller.state.permissionActivityLog[3]); + logMiddleware(req, res, mockNext(true), noop); - // Regression test: ensure "response" and "request" properties - // are not present - controller.state.permissionActivityLog.forEach((entry) => - expect('request' in entry && 'response' in entry).toBe(false), - ); + 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 = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.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 = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = controller.createMiddleware(); const handlerArray: JsonRpcEngineReturnHandler[] = []; const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); @@ -251,17 +234,7 @@ describe('PermissionLogController', () => { result: [id1], }; - logMiddleware( - { - ...req, - }, - { - ...PendingJsonRpcResponseStruct.TYPE, - ...res1, - }, - getSavedMockNext(handlerArray), - noop, - ); + logMiddleware(req, res1, getSavedMockNext(handlerArray, true), noop); const id2 = nanoid(); req.id = id2; @@ -269,7 +242,7 @@ describe('PermissionLogController', () => { ...PendingJsonRpcResponseStruct.TYPE, result: [id2], }; - logMiddleware(req, res2, getSavedMockNext(handlerArray), noop); + logMiddleware(req, res2, getSavedMockNext(handlerArray, true), noop); const id3 = nanoid(); req.id = id3; @@ -277,96 +250,135 @@ describe('PermissionLogController', () => { ...PendingJsonRpcResponseStruct.TYPE, result: [id3], }; - logMiddleware(req, res3, getSavedMockNext(handlerArray), noop); - - // verify log state - expect(controller.state.permissionActivityLog).toHaveLength(3); - const entry1 = controller.state.permissionActivityLog[0]; - const entry2 = controller.state.permissionActivityLog[1]; - const entry3 = controller.state.permissionActivityLog[2]; + logMiddleware(req, res3, getSavedMockNext(handlerArray, true), noop); // all entries should be in correct order - expect(entry1).toMatchObject({ id: id1, responseTime: null }); - expect(entry2).toMatchObject({ id: id2, responseTime: null }); - expect(entry3).toMatchObject({ id: id3, responseTime: null }); + expect(controller.state.permissionActivityLog).toMatchObject([ + { + id: id1, + responseTime: null, + }, + { + id: id2, + responseTime: null, + }, + { + id: id3, + responseTime: null, + }, + ]); - // call response handlers for (const i of [1, 2, 0]) { handlerArray[i](noop); } - // verify log state again - expect(controller.state.permissionActivityLog).toHaveLength(3); - // verify all entries - validateActivityEntry( - controller.state.permissionActivityLog[0], - { ...req, id: id1 }, - { ...res1 }, - LOG_METHOD_TYPES.restricted, - true, - ); - validateActivityEntry( - controller.state.permissionActivityLog[1], - { ...req, id: id2 }, - { ...res2 }, - LOG_METHOD_TYPES.restricted, - true, - ); - validateActivityEntry( - controller.state.permissionActivityLog[2], - { ...req, id: id3 }, - { ...res3 }, - LOG_METHOD_TYPES.restricted, - true, - ); + 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', () => { - let req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); - req.id = REQUEST_IDS.a; - let res = { - ...PendingJsonRpcResponseStruct.TYPE, - result: ['bar'], + const controller = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = controller.createMiddleware(); + const req1 = { + ...RPC_REQUESTS.test_method(SUBJECTS.a.origin), + id: REQUEST_IDS.a, }; // noop for next handler prevents recording of response - logMiddleware(req, res, noop, noop); - - expect(controller.state.permissionActivityLog).toHaveLength(1); - const entry1 = controller.state.permissionActivityLog[0]; - validateActivityEntry( - entry1, - req, - null, - LOG_METHOD_TYPES.restricted, - true, + 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 - req = RPC_REQUESTS.eth_accounts(SUBJECTS.b.origin); - req.id = REQUEST_IDS.b; - res = { - ...PendingJsonRpcResponseStruct.TYPE, - result: ACCOUNTS.b.permitted, + const req2 = { + ...RPC_REQUESTS.test_method(SUBJECTS.b.origin), + id: REQUEST_IDS.b, }; - logMiddleware(req, res, mockNext, noop); - - expect(controller.state.permissionActivityLog).toHaveLength(2); - const entry2 = controller.state.permissionActivityLog[1]; - validateActivityEntry( - entry2, - req, - res, - LOG_METHOD_TYPES.restricted, - true, + logMiddleware( + req2, + { + ...PendingJsonRpcResponseStruct.TYPE, + result: ACCOUNTS.b.permitted, + }, + mockNext(true), + noop, ); - // validate final state - expect(entry1).toStrictEqual(controller.state.permissionActivityLog[0]); - expect(entry2).toStrictEqual(controller.state.permissionActivityLog[1]); + + 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 expected methods', () => { + it('ignores activity for expected methods', () => { + const controller = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.createMiddleware(); expect(controller.state.permissionActivityLog).toHaveLength(0); const res = { @@ -374,89 +386,81 @@ describe('PermissionLogController', () => { result: ['bar'], }; - logMiddleware( + const ignoredMethods = [ RPC_REQUESTS.metamask_sendDomainMetadata(SUBJECTS.c.origin, 'foobar'), - res, - mockNext, - noop, - ); - logMiddleware( RPC_REQUESTS.custom(SUBJECTS.b.origin, 'eth_getBlockNumber'), - res, - mockNext, - noop, - ); - logMiddleware( RPC_REQUESTS.custom(SUBJECTS.b.origin, 'net_version'), - res, - mockNext, - noop, - ); + ]; + + ignoredMethods.forEach((req) => { + logMiddleware(req, res, mockNext(false), noop); + }); expect(controller.state.permissionActivityLog).toHaveLength(0); }); - it('enforces log limit', () => { + it('fills up the log to its limit without exceeding', () => { + const controller = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = controller.createMiddleware(); const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); - const res = { - ...PendingJsonRpcResponseStruct.TYPE, - result: ['bar'], - }; + const res = { ...PendingJsonRpcResponseStruct.TYPE, result: ['bar'] }; - // max out log - let lastId; for (let i = 0; i < LOG_LIMIT; i++) { - lastId = nanoid(); - logMiddleware({ ...req, id: lastId }, res, mockNext, noop); + logMiddleware({ ...req, id: nanoid() }, res, mockNext(false), noop); } - // check last entry valid expect(controller.state.permissionActivityLog).toHaveLength(LOG_LIMIT); + }); - validateActivityEntry( - controller.state.permissionActivityLog[LOG_LIMIT - 1], - { ...req, id: lastId ?? null }, - res, - LOG_METHOD_TYPES.restricted, - true, - ); + it('removes the oldest log entry when a new one is added after reaching the limit', () => { + const controller = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = controller.createMiddleware(); + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); + const res = { ...PendingJsonRpcResponseStruct.TYPE, result: ['bar'] }; - // store the id of the current second entry - const nextFirstId = controller.state.permissionActivityLog[1].id; - // add one more entry to log, putting it over the limit - lastId = nanoid(); + for (let i = 0; i < LOG_LIMIT; i++) { + logMiddleware({ ...req, id: nanoid() }, res, mockNext(false), noop); + } - logMiddleware({ ...req, id: lastId }, res, mockNext, noop); + const firstLogIdAfterFilling = + controller.state.permissionActivityLog[0].id; - // check log length - expect(controller.state.permissionActivityLog).toHaveLength(LOG_LIMIT); + const newLogId = nanoid(); + logMiddleware({ ...req, id: newLogId }, res, mockNext(false), noop); - // check first and last entries - validateActivityEntry( - controller.state.permissionActivityLog[0], - { ...req, id: nextFirstId }, - res, - LOG_METHOD_TYPES.restricted, - true, + 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(); + }); - validateActivityEntry( - controller.state.permissionActivityLog[LOG_LIMIT - 1], - { ...req, id: lastId }, - res, - LOG_METHOD_TYPES.restricted, - true, - ); + it('ensures the log does not exceed the limit when adding multiple entries', () => { + const controller = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = controller.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', () => { - let permissionLogController: PermissionLogController; - let logMiddleware: JsonRpcMiddleware; - beforeEach(() => { - permissionLogController = initPermissionLogController(); - logMiddleware = initMiddleware(permissionLogController); initClock(); }); @@ -465,6 +469,10 @@ describe('PermissionLogController', () => { }); it('only updates history on responses', () => { + const controller = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.createMiddleware(); const req = RPC_REQUESTS.requestPermission( SUBJECTS.a.origin, PERM_NAMES.test_method, @@ -477,19 +485,21 @@ describe('PermissionLogController', () => { // noop => no response logMiddleware(req, res, noop, noop); - expect(permissionLogController.state.permissionHistory).toStrictEqual( - {}, - ); + expect(controller.state.permissionHistory).toStrictEqual({}); // response => records granted permissions - logMiddleware(req, res, mockNext, noop); + logMiddleware(req, res, mockNext(false), noop); - const permHistory = permissionLogController.state.permissionHistory; - expect(Object.keys(permHistory)).toHaveLength(1); - expect(permHistory[SUBJECTS.a.origin]).toBeDefined(); + const { permissionHistory } = controller.state; + expect(Object.keys(permissionHistory)).toHaveLength(1); + expect(permissionHistory[SUBJECTS.a.origin]).toBeDefined(); }); it('ignores malformed permissions requests', () => { + const controller = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.createMiddleware(); const req = RPC_REQUESTS.requestPermission( SUBJECTS.a.origin, PERM_NAMES.test_method, @@ -506,16 +516,18 @@ describe('PermissionLogController', () => { params: undefined, }, res, - mockNext, + mockNext(false), noop, ); - expect(permissionLogController.state.permissionHistory).toStrictEqual( - {}, - ); + expect(controller.state.permissionHistory).toStrictEqual({}); }); it('records and updates account history as expected', async () => { + const controller = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.createMiddleware(); const req = RPC_REQUESTS.requestPermission( SUBJECTS.a.origin, PERM_NAMES.eth_accounts, @@ -525,9 +537,9 @@ describe('PermissionLogController', () => { result: [PERMS.granted.eth_accounts(ACCOUNTS.a.permitted)], }; - logMiddleware(req, res, mockNext, noop); + logMiddleware(req, res, mockNext(false), noop); - expect(permissionLogController.state.permissionHistory).toStrictEqual( + expect(controller.state.permissionHistory).toStrictEqual( EXPECTED_HISTORIES.case1[0], ); @@ -535,14 +547,18 @@ describe('PermissionLogController', () => { jest.advanceTimersByTime(1); res.result = [PERMS.granted.eth_accounts([ACCOUNTS.a.permitted[0]])]; - logMiddleware(req, res, mockNext, noop); + logMiddleware(req, res, mockNext(false), noop); - expect(permissionLogController.state.permissionHistory).toStrictEqual( + expect(controller.state.permissionHistory).toStrictEqual( EXPECTED_HISTORIES.case1[1], ); }); it('handles eth_accounts response without caveats', async () => { + const controller = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.createMiddleware(); const req = RPC_REQUESTS.requestPermission( SUBJECTS.a.origin, PERM_NAMES.eth_accounts, @@ -553,14 +569,18 @@ describe('PermissionLogController', () => { }; delete res.result?.[0].caveats; - logMiddleware(req, res, mockNext, noop); + logMiddleware(req, res, mockNext(false), noop); - expect(permissionLogController.state.permissionHistory).toStrictEqual( + expect(controller.state.permissionHistory).toStrictEqual( EXPECTED_HISTORIES.case2[0], ); }); it('handles extra caveats for eth_accounts', async () => { + const controller = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.createMiddleware(); const req = RPC_REQUESTS.requestPermission( SUBJECTS.a.origin, PERM_NAMES.eth_accounts, @@ -572,9 +592,9 @@ describe('PermissionLogController', () => { // @ts-expect-error We are intentionally passing bad input. res.result[0].caveats.push({ foo: 'bar' }); - logMiddleware(req, res, mockNext, noop); + logMiddleware(req, res, mockNext(false), noop); - expect(permissionLogController.state.permissionHistory).toStrictEqual( + expect(controller.state.permissionHistory).toStrictEqual( EXPECTED_HISTORIES.case1[0], ); }); @@ -582,6 +602,10 @@ describe('PermissionLogController', () => { // wallet_requestPermissions returns all permissions approved for the // requesting origin, including old ones it('handles unrequested permissions on the response', async () => { + const controller = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.createMiddleware(); const req = RPC_REQUESTS.requestPermission( SUBJECTS.a.origin, PERM_NAMES.eth_accounts, @@ -594,14 +618,18 @@ describe('PermissionLogController', () => { ], }; - logMiddleware(req, res, mockNext, noop); + logMiddleware(req, res, mockNext(false), noop); - expect(permissionLogController.state.permissionHistory).toStrictEqual( + expect(controller.state.permissionHistory).toStrictEqual( EXPECTED_HISTORIES.case1[0], ); }); it('does not update history if no new permissions are approved', async () => { + const controller = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.createMiddleware(); let req = RPC_REQUESTS.requestPermission( SUBJECTS.a.origin, PERM_NAMES.test_method, @@ -611,14 +639,13 @@ describe('PermissionLogController', () => { result: [PERMS.granted.test_method()], }; - logMiddleware(req, res, mockNext, noop); + logMiddleware(req, res, mockNext(false), noop); - expect(permissionLogController.state.permissionHistory).toStrictEqual( + expect(controller.state.permissionHistory).toStrictEqual( EXPECTED_HISTORIES.case4[0], ); // new permission requested, but not approved - jest.advanceTimersByTime(1); req = RPC_REQUESTS.requestPermission( @@ -630,116 +657,107 @@ describe('PermissionLogController', () => { result: [PERMS.granted.test_method()], }; - logMiddleware(req, res, mockNext, noop); + logMiddleware(req, res, mockNext(false), noop); // history should be unmodified - expect(permissionLogController.state.permissionHistory).toStrictEqual( + expect(controller.state.permissionHistory).toStrictEqual( EXPECTED_HISTORIES.case4[0], ); }); it('records and updates history for multiple origins, regardless of response order', async () => { - // make first round of requests + const controller = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = controller.createMiddleware(); const round1: { req: JsonRpcRequest; res: PendingJsonRpcResponse; - }[] = []; - const handlers1: JsonRpcEngineReturnHandler[] = []; - - // first origin - round1.push({ - req: RPC_REQUESTS.requestPermission( - SUBJECTS.a.origin, - PERM_NAMES.test_method, - ), - res: { - ...PendingJsonRpcResponseStruct.TYPE, - result: [PERMS.granted.test_method()], + }[] = [ + { + req: RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.test_method, + ), + res: { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.test_method()], + }, }, - }); - - // second origin - round1.push({ - 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.requestPermission( + SUBJECTS.b.origin, + PERM_NAMES.eth_accounts, + ), + res: { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.eth_accounts(ACCOUNTS.b.permitted)], + }, }, - }); - - // third origin - round1.push({ - 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), - ], + { + 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((x) => { - logMiddleware(x.req, x.res, getSavedMockNext(handlers1), noop); + logMiddleware(x.req, x.res, getSavedMockNext(handlers1, false), noop); }); for (const i of [1, 2, 0]) { handlers1[i](noop); } - expect(permissionLogController.state.permissionHistory).toStrictEqual( + 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; - }[] = []; - // we're just gonna process these in order - - // first origin - round2.push({ - req: RPC_REQUESTS.requestPermission( - SUBJECTS.a.origin, - PERM_NAMES.test_method, - ), - res: { - ...PendingJsonRpcResponseStruct.TYPE, - result: [PERMS.granted.test_method()], + }[] = [ + { + req: RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.test_method, + ), + res: { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.test_method()], + }, }, - }); - - // nothing for second origin - - // third origin - round2.push({ - req: RPC_REQUESTS.requestPermissions(SUBJECTS.c.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.eth_accounts]: {}, + }), + res: { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.eth_accounts(ACCOUNTS.b.permitted)], + }, }, - }); + ]; - // make requests round2.forEach((x) => { - logMiddleware(x.req, x.res, mockNext, noop); + logMiddleware(x.req, x.res, mockNext(false), noop); }); - expect(permissionLogController.state.permissionHistory).toStrictEqual( + expect(controller.state.permissionHistory).toStrictEqual( EXPECTED_HISTORIES.case3[1], ); }); @@ -756,7 +774,9 @@ describe('PermissionLogController', () => { }); it('does nothing if the list of accounts is empty', () => { - const controller = initPermissionLogController(); + const controller = initController({ + restrictedMethods: new Set([]), + }); controller.updateAccountsHistory('foo.com', []); @@ -764,14 +784,17 @@ describe('PermissionLogController', () => { }); it('updates the account history', () => { - const controller = initPermissionLogController({ - permissionHistory: { - 'foo.com': { - [PERM_NAMES.eth_accounts]: { - accounts: { - '0x1': 1, + const controller = initController({ + restrictedMethods: new Set(['eth_accounts']), + state: { + permissionHistory: { + 'foo.com': { + [PERM_NAMES.eth_accounts]: { + accounts: { + '0x1': 1, + }, + lastApproved: 1, }, - lastApproved: 1, }, }, }, diff --git a/packages/permission-log-controller/tests/helpers.ts b/packages/permission-log-controller/tests/helpers.ts index a6896655c4..eab0c88831 100644 --- a/packages/permission-log-controller/tests/helpers.ts +++ b/packages/permission-log-controller/tests/helpers.ts @@ -288,8 +288,6 @@ export const constants = deepFreeze({ PERM_NAMES: { ...PERM_NAMES }, - RESTRICTED_METHODS: new Set(['eth_accounts', 'test_method']), - /** * Mock permissions history objects. */ diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 9315e65bdb..8d475c2704 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@types/punycode": "^2.1.0", "eth-phishing-detect": "^1.2.0", "punycode": "^2.1.1" diff --git a/packages/polling-controller/package.json b/packages/polling-controller/package.json index 0ba70d0ae0..1425614e07 100644 --- a/packages/polling-controller/package.json +++ b/packages/polling-controller/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/network-controller": "^17.2.0", "@metamask/utils": "^8.3.0", "@types/uuid": "^8.3.0", diff --git a/packages/preferences-controller/jest.config.js b/packages/preferences-controller/jest.config.js index 44b8389d30..bbde231ebc 100644 --- a/packages/preferences-controller/jest.config.js +++ b/packages/preferences-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: 85.71, - functions: 93.75, - lines: 92.54, - statements: 92.54, + branches: 88.23, + functions: 95.12, + lines: 95.87, + statements: 95.87, }, }, }); diff --git a/packages/preferences-controller/package.json b/packages/preferences-controller/package.json index ada3a57114..bc49934de2 100644 --- a/packages/preferences-controller/package.json +++ b/packages/preferences-controller/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2" + "@metamask/controller-utils": "^8.0.3" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/preferences-controller/src/PreferencesController.test.ts b/packages/preferences-controller/src/PreferencesController.test.ts index 4d4947f0ec..31e65226e5 100644 --- a/packages/preferences-controller/src/PreferencesController.test.ts +++ b/packages/preferences-controller/src/PreferencesController.test.ts @@ -139,6 +139,35 @@ describe('PreferencesController', () => { expect(controller.state.selectedAddress).toBe('0x00'); }); + it('should maintain existing identities when no accounts are present in keyrings', () => { + const identitiesState = { + '0x00': { address: '0x00', importTime: 1, name: 'Account 1' }, + '0x01': { address: '0x01', importTime: 2, name: 'Account 2' }, + '0x02': { address: '0x02', importTime: 3, name: 'Account 3' }, + }; + const messenger = getControllerMessenger(); + const controller = setupPreferencesController({ + options: { + state: { + identities: cloneDeep(identitiesState), + selectedAddress: '0x00', + }, + }, + messenger, + }); + + messenger.publish( + 'KeyringController:stateChange', + { + ...getDefaultKeyringState(), + keyrings: [{ accounts: [], type: 'CustomKeyring' }], + }, + [], + ); + + expect(controller.state.identities).toStrictEqual(identitiesState); + }); + it('should not update existing identities', () => { const identitiesState = { '0x00': { address: '0x00', importTime: 1, name: 'Account 1' }, @@ -305,110 +334,6 @@ describe('PreferencesController', () => { expect(controller.state.identities['0x01'].name).toBe('qux'); }); - it('should sync identities', () => { - const controller = setupPreferencesController(); - controller.addIdentities(['0x00', '0x01']); - controller.syncIdentities(['0x00', '0x01']); - expect(controller.state.identities['0x00'].address).toBe('0x00'); - expect(controller.state.identities['0x00'].name).toBe('Account 1'); - expect(controller.state.identities['0x00'].importTime).toBeLessThanOrEqual( - Date.now(), - ); - expect(controller.state.identities['0x01'].address).toBe('0x01'); - expect(controller.state.identities['0x01'].name).toBe('Account 2'); - expect(controller.state.identities['0x01'].importTime).toBeLessThanOrEqual( - Date.now(), - ); - controller.syncIdentities(['0x00']); - expect(controller.state.identities['0x00'].address).toBe('0x00'); - expect(controller.state.identities['0x00'].name).toBe('Account 1'); - expect(controller.state.selectedAddress).toBe('0x00'); - }); - - it('should add new identities', () => { - const controller = setupPreferencesController(); - controller.updateIdentities(['0x00', '0x01']); - expect(controller.state.identities['0x00'].address).toBe('0x00'); - expect(controller.state.identities['0x00'].name).toBe('Account 1'); - expect(controller.state.identities['0x00'].importTime).toBeLessThanOrEqual( - Date.now(), - ); - expect(controller.state.identities['0x01'].address).toBe('0x01'); - expect(controller.state.identities['0x01'].name).toBe('Account 2'); - expect(controller.state.identities['0x01'].importTime).toBeLessThanOrEqual( - Date.now(), - ); - }); - - it('should not update existing identities', () => { - const controller = setupPreferencesController({ - options: { - state: { - identities: { '0x01': { address: '0x01', name: 'Custom name' } }, - }, - }, - }); - controller.updateIdentities(['0x00', '0x01']); - expect(controller.state.identities['0x00'].address).toBe('0x00'); - expect(controller.state.identities['0x00'].name).toBe('Account 1'); - expect(controller.state.identities['0x00'].importTime).toBeLessThanOrEqual( - Date.now(), - ); - expect(controller.state.identities['0x01'].address).toBe('0x01'); - expect(controller.state.identities['0x01'].name).toBe('Custom name'); - expect(controller.state.identities['0x01'].importTime).toBeUndefined(); - }); - - it('should remove identities', () => { - const controller = setupPreferencesController({ - options: { - state: { - identities: { - '0x01': { address: '0x01', name: 'Account 2' }, - '0x00': { address: '0x00', name: 'Account 1' }, - }, - }, - }, - }); - controller.updateIdentities(['0x00']); - expect(controller.state.identities).toStrictEqual({ - '0x00': { address: '0x00', name: 'Account 1' }, - }); - }); - - it('should not update selected address if it is still among identities', () => { - const controller = setupPreferencesController({ - options: { - state: { - identities: { - '0x01': { address: '0x01', name: 'Account 2' }, - '0x00': { address: '0x00', name: 'Account 1' }, - }, - selectedAddress: '0x01', - }, - }, - }); - controller.updateIdentities(['0x00', '0x01']); - expect(controller.state.selectedAddress).toBe('0x01'); - }); - - it('should update selected address to first identity if it was removed from identities', () => { - const controller = setupPreferencesController({ - options: { - state: { - identities: { - '0x01': { address: '0x01', name: 'Account 2' }, - '0x02': { address: '0x02', name: 'Account 3' }, - '0x00': { address: '0x00', name: 'Account 1' }, - }, - selectedAddress: '0x02', - }, - }, - }); - controller.updateIdentities(['0x00', '0x01']); - expect(controller.state.selectedAddress).toBe('0x00'); - }); - it('should set IPFS gateway', () => { const controller = setupPreferencesController(); controller.setIpfsGateway('https://ipfs.infura.io/ipfs/'); @@ -436,6 +361,24 @@ describe('PreferencesController', () => { expect(controller.state.useNftDetection).toBe(true); }); + it('should throw an error when useNftDetection is set and openSeaEnabled is false', () => { + const controller = setupPreferencesController(); + controller.setOpenSeaEnabled(false); + expect(() => controller.setUseNftDetection(true)).toThrow( + 'useNftDetection cannot be enabled if openSeaEnabled is false', + ); + }); + + it('should set featureFlags', () => { + const controller = setupPreferencesController(); + controller.setFeatureFlag('Feature A', true); + controller.setFeatureFlag('Feature B', false); + expect(controller.state.featureFlags).toStrictEqual({ + 'Feature A': true, + 'Feature B': false, + }); + }); + it('should set securityAlertsEnabled', () => { const controller = setupPreferencesController(); controller.setSecurityAlertsEnabled(true); diff --git a/packages/preferences-controller/src/PreferencesController.ts b/packages/preferences-controller/src/PreferencesController.ts index 060b162c38..b7e53e5d69 100644 --- a/packages/preferences-controller/src/PreferencesController.ts +++ b/packages/preferences-controller/src/PreferencesController.ts @@ -240,7 +240,9 @@ export class PreferencesController extends BaseController< accounts.add(account); } } - this.syncIdentities(Array.from(accounts)); + if (accounts.size > 0) { + this.#syncIdentities(Array.from(accounts)); + } }, ); } @@ -319,10 +321,8 @@ export class PreferencesController extends BaseController< * Synchronizes the current identity list with new identities. * * @param addresses - List of addresses corresponding to identities to sync. - * @returns Newly-selected address after syncing. - * @deprecated This will be removed in a future release */ - syncIdentities(addresses: string[]) { + #syncIdentities(addresses: string[]) { addresses = addresses.map((address: string) => toChecksumHexAddress(address), ); @@ -349,38 +349,6 @@ export class PreferencesController extends BaseController< state.selectedAddress = addresses[0]; }); } - - return this.state.selectedAddress; - } - - /** - * Generates and stores a new list of stored identities based on address. If the selected address - * is unset, or if it refers to an identity that was removed, it will be set to the first - * identity. - * - * @param addresses - List of addresses to use as a basis for each identity. - */ - updateIdentities(addresses: string[]) { - addresses = addresses.map((address: string) => - toChecksumHexAddress(address), - ); - this.update((state) => { - const identities = addresses.reduce( - (ids: { [address: string]: Identity }, address, index) => { - ids[address] = state.identities[address] || { - address, - name: `Account ${index + 1}`, - importTime: Date.now(), - }; - return ids; - }, - {}, - ); - state.identities = identities; - if (!Object.keys(identities).includes(state.selectedAddress)) { - state.selectedAddress = Object.keys(identities)[0]; - } - }); } /** diff --git a/packages/queued-request-controller/CHANGELOG.md b/packages/queued-request-controller/CHANGELOG.md index f8fdcc682d..0aa6b118e7 100644 --- a/packages/queued-request-controller/CHANGELOG.md +++ b/packages/queued-request-controller/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +## [0.5.0] + +### Changed + +- **BREAKING:** Bump `@metamask/selected-network-controller` peer dependency to `^8.0.0` ([#3958](https://github.com/MetaMask/core/pull/3958)) + ## [0.4.0] ### Changed @@ -92,7 +98,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@0.4.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@0.5.0...HEAD +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@0.4.0...@metamask/queued-request-controller@0.5.0 [0.4.0]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@0.3.0...@metamask/queued-request-controller@0.4.0 [0.3.0]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@0.2.0...@metamask/queued-request-controller@0.3.0 [0.2.0]: https://github.com/MetaMask/core/compare/@metamask/queued-request-controller@0.1.4...@metamask/queued-request-controller@0.2.0 diff --git a/packages/queued-request-controller/package.json b/packages/queued-request-controller/package.json index b61f6be097..b5fafee22f 100644 --- a/packages/queued-request-controller/package.json +++ b/packages/queued-request-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/queued-request-controller", - "version": "0.4.0", + "version": "0.5.0", "description": "Includes a controller and middleware that implements a request queue", "keywords": [ "MetaMask", @@ -32,9 +32,9 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/json-rpc-engine": "^7.3.2", - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/swappable-obj-proxy": "^2.2.0", "@metamask/utils": "^8.3.0" }, @@ -42,7 +42,7 @@ "@metamask/approval-controller": "^5.1.2", "@metamask/auto-changelog": "^3.4.4", "@metamask/network-controller": "^17.2.0", - "@metamask/selected-network-controller": "^7.0.1", + "@metamask/selected-network-controller": "^8.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "immer": "^9.0.6", @@ -58,7 +58,7 @@ "peerDependencies": { "@metamask/approval-controller": "^5.1.2", "@metamask/network-controller": "^17.2.0", - "@metamask/selected-network-controller": "^7.0.1" + "@metamask/selected-network-controller": "^8.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/queued-request-controller/src/QueuedRequestController.test.ts b/packages/queued-request-controller/src/QueuedRequestController.test.ts index b0923808aa..4839d82654 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.test.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.test.ts @@ -1,6 +1,16 @@ +import type { AddApprovalRequest } from '@metamask/approval-controller'; import { ControllerMessenger } from '@metamask/base-controller'; +import { + defaultState as defaultNetworkState, + type NetworkControllerGetNetworkConfigurationByNetworkClientId, + type NetworkControllerGetStateAction, + type NetworkControllerSetActiveNetworkAction, +} from '@metamask/network-controller'; +import { createDeferredPromise } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; import type { + AllowedActions, QueuedRequestControllerActions, QueuedRequestControllerEvents, QueuedRequestControllerMessenger, @@ -10,23 +20,7 @@ import { QueuedRequestController, controllerName, } from './QueuedRequestController'; - -/** - * Builds a restricted controller messenger for the queued request controller. - * - * @param messenger - A controller messenger. - * @returns The restricted controller messenger. - */ -function buildQueuedRequestControllerMessenger( - messenger = new ControllerMessenger< - QueuedRequestControllerActions, - QueuedRequestControllerEvents - >(), -): QueuedRequestControllerMessenger { - return messenger.getRestricted({ - name: controllerName, - }); -} +import type { QueuedRequestMiddlewareJsonRpcRequest } from './types'; describe('QueuedRequestController', () => { it('can be instantiated with default values', () => { @@ -35,10 +29,47 @@ describe('QueuedRequestController', () => { }; const controller = new QueuedRequestController(options); - expect(controller.state).toStrictEqual({}); + expect(controller.state).toStrictEqual({ queuedRequestCount: 0 }); }); describe('enqueueRequest', () => { + it('counts a request as queued during processing', async () => { + const options: QueuedRequestControllerOptions = { + messenger: buildQueuedRequestControllerMessenger(), + }; + const controller = new QueuedRequestController(options); + + await controller.enqueueRequest(buildRequest(), async () => { + expect(controller.state.queuedRequestCount).toBe(1); + }); + expect(controller.state.queuedRequestCount).toBe(0); + }); + + it('counts a request as queued while waiting on another request to finish processing', async () => { + const options: QueuedRequestControllerOptions = { + messenger: buildQueuedRequestControllerMessenger(), + }; + const controller = new QueuedRequestController(options); + const { promise: firstRequestProcessing, resolve: resolveFirstRequest } = + createDeferredPromise(); + const firstRequest = controller.enqueueRequest( + buildRequest(), + () => firstRequestProcessing, + ); + const secondRequest = controller.enqueueRequest( + buildRequest(), + async () => { + expect(controller.state.queuedRequestCount).toBe(1); + }, + ); + + expect(controller.state.queuedRequestCount).toBe(2); + + resolveFirstRequest(); + await firstRequest; + await secondRequest; + }); + it('runs the next request immediately when the queue is empty', async () => { const options: QueuedRequestControllerOptions = { messenger: buildQueuedRequestControllerMessenger(), @@ -49,12 +80,94 @@ describe('QueuedRequestController', () => { // Mock requestNext function const requestNext = jest.fn(() => Promise.resolve()); - await controller.enqueueRequest(requestNext); + await controller.enqueueRequest(buildRequest(), requestNext); // Expect that the request was called expect(requestNext).toHaveBeenCalledTimes(1); }); + it('switches network if a request comes in for a different selected chain', async () => { + const mockSetActiveNetwork = jest.fn(); + const { messenger } = buildControllerMessenger({ + networkControllerGetState: jest.fn().mockReturnValue({ + ...cloneDeep(defaultNetworkState), + selectedNetworkClientId: 'selectedNetworkClientId', + }), + networkControllerSetActiveNetwork: mockSetActiveNetwork, + }); + const options: QueuedRequestControllerOptions = { + messenger: buildQueuedRequestControllerMessenger(messenger), + }; + const controller = new QueuedRequestController(options); + + await controller.enqueueRequest( + { + ...buildRequest(), + networkClientId: 'differentNetworkClientId', + }, + () => new Promise((resolve) => setTimeout(resolve, 10)), + ); + + expect(mockSetActiveNetwork).toHaveBeenCalledWith( + 'differentNetworkClientId', + ); + }); + + it('does not switch networks if a request comes in for the same chain', async () => { + const mockSetActiveNetwork = jest.fn(); + const { messenger } = buildControllerMessenger({ + networkControllerGetState: jest.fn().mockReturnValue({ + ...cloneDeep(defaultNetworkState), + selectedNetworkClientId: 'selectedNetworkClientId', + }), + networkControllerSetActiveNetwork: mockSetActiveNetwork, + }); + const options: QueuedRequestControllerOptions = { + messenger: buildQueuedRequestControllerMessenger(messenger), + }; + const controller = new QueuedRequestController(options); + + await controller.enqueueRequest( + { + ...buildRequest(), + networkClientId: 'selectedNetworkClientId', + }, + () => new Promise((resolve) => setTimeout(resolve, 10)), + ); + + expect(mockSetActiveNetwork).not.toHaveBeenCalled(); + }); + + it('does not switch networks if the switch chain confirmation is rejected', async () => { + const mockSetActiveNetwork = jest.fn(); + const { messenger } = buildControllerMessenger({ + approvalControllerAddRequest: jest + .fn() + .mockRejectedValue(new Error('Rejected')), + networkControllerGetState: jest.fn().mockReturnValue({ + ...cloneDeep(defaultNetworkState), + selectedNetworkClientId: 'selectedNetworkClientId', + }), + networkControllerSetActiveNetwork: mockSetActiveNetwork, + }); + const options: QueuedRequestControllerOptions = { + messenger: buildQueuedRequestControllerMessenger(messenger), + }; + const controller = new QueuedRequestController(options); + + await expect(() => + controller.enqueueRequest( + { + ...buildRequest(), + networkClientId: 'differentNetworkClientId', + }, + () => new Promise((resolve) => setTimeout(resolve, 10)), + ), + ).rejects.toThrow('Rejected'); + + expect(mockSetActiveNetwork).not.toHaveBeenCalled(); + }); + it('runs each request sequentially in the correct order', async () => { const options: QueuedRequestControllerOptions = { messenger: buildQueuedRequestControllerMessenger(), @@ -67,13 +180,13 @@ describe('QueuedRequestController', () => { const executionOrder: string[] = []; // Enqueue requests - controller.enqueueRequest(async () => { + controller.enqueueRequest(buildRequest(), async () => { executionOrder.push('Request 1 Start'); await new Promise((resolve) => setTimeout(resolve, 10)); executionOrder.push('Request 1 End'); }); - await controller.enqueueRequest(async () => { + await controller.enqueueRequest(buildRequest(), async () => { executionOrder.push('Request 2 Start'); await new Promise((resolve) => setTimeout(resolve, 10)); executionOrder.push('Request 2 End'); @@ -103,9 +216,89 @@ describe('QueuedRequestController', () => { // Enqueue the request await expect(() => - controller.enqueueRequest(requestWithError), + controller.enqueueRequest(buildRequest(), requestWithError), ).rejects.toThrow(new Error('Request failed')); - expect(controller.length()).toBe(0); + expect(controller.state.queuedRequestCount).toBe(0); + }); + + it('rejects requests that require a switch if they are missing network configuration', async () => { + const mockSetActiveNetwork = jest.fn(); + const { messenger } = buildControllerMessenger({ + networkControllerGetState: jest.fn().mockReturnValue({ + ...cloneDeep(defaultNetworkState), + selectedNetworkClientId: 'selectedNetworkClientId', + }), + networkControllerGetNetworkConfigurationByNetworkClientId: ( + networkClientId, + ) => + networkClientId === 'selectedNetworkClientId' + ? { chainId: '0x999', rpcUrl: 'metamask.io', ticker: 'TEST' } + : undefined, + networkControllerSetActiveNetwork: mockSetActiveNetwork, + }); + const options: QueuedRequestControllerOptions = { + messenger: buildQueuedRequestControllerMessenger(messenger), + }; + const controller = new QueuedRequestController(options); + + await expect(() => + controller.enqueueRequest( + { + ...buildRequest(), + networkClientId: 'differentNetworkClientId', + }, + () => new Promise((resolve) => setTimeout(resolve, 10)), + ), + ).rejects.toThrow( + 'Missing network configuration for differentNetworkClientId', + ); + }); + + it('rejects all requests that require a switch if the selected network network configuration is missing', async () => { + const mockSetActiveNetwork = jest.fn(); + const { messenger } = buildControllerMessenger({ + networkControllerGetState: jest.fn().mockReturnValue({ + ...cloneDeep(defaultNetworkState), + selectedNetworkClientId: 'selectedNetworkClientId', + }), + networkControllerGetNetworkConfigurationByNetworkClientId: ( + networkClientId, + ) => + networkClientId === 'differentNetworkClientId' + ? { chainId: '0x999', rpcUrl: 'metamask.io', ticker: 'TEST' } + : undefined, + networkControllerSetActiveNetwork: mockSetActiveNetwork, + }); + const options: QueuedRequestControllerOptions = { + messenger: buildQueuedRequestControllerMessenger(messenger), + }; + const controller = new QueuedRequestController(options); + + await expect(() => + controller.enqueueRequest( + { + ...buildRequest(), + networkClientId: 'differentNetworkClientId', + }, + () => new Promise((resolve) => setTimeout(resolve, 10)), + ), + ).rejects.toThrow( + 'Missing network configuration for selectedNetworkClientId', + ); + }); + + it('correctly updates the request queue count upon failure', async () => { + const options: QueuedRequestControllerOptions = { + messenger: buildQueuedRequestControllerMessenger(), + }; + const controller = new QueuedRequestController(options); + + await expect(() => + controller.enqueueRequest(buildRequest(), async () => { + throw new Error('Request failed'); + }), + ).rejects.toThrow('Request failed'); + expect(controller.state.queuedRequestCount).toBe(0); }); it('handles errors without interrupting the execution of the next item in the queue', async () => { @@ -117,11 +310,11 @@ describe('QueuedRequestController', () => { // Mock requests with one request throwing an error const request1 = jest.fn(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); + throw new Error('Request 1 failed'); }); const request2 = jest.fn(async () => { - throw new Error('Request 2 failed'); + await new Promise((resolve) => setTimeout(resolve, 100)); }); const request3 = jest.fn(async () => { @@ -129,111 +322,140 @@ describe('QueuedRequestController', () => { }); // Enqueue the requests - const promise1 = controller.enqueueRequest(request1); - const promise2 = controller.enqueueRequest(request2); - const promise3 = controller.enqueueRequest(request3); - - await expect(() => - Promise.all([promise1, promise2, promise3]), - ).rejects.toStrictEqual(new Error('Request 2 failed')); - // Ensure that request3 still executed despite the error in request2 + const promise1 = controller.enqueueRequest(buildRequest(), request1); + const promise2 = controller.enqueueRequest(buildRequest(), request2); + const promise3 = controller.enqueueRequest(buildRequest(), request3); + + expect( + await Promise.allSettled([promise1, promise2, promise3]), + ).toStrictEqual([ + { status: 'rejected', reason: new Error('Request 1 failed') }, + { status: 'fulfilled', value: undefined }, + { status: 'fulfilled', value: undefined }, + ]); expect(request1).toHaveBeenCalled(); expect(request2).toHaveBeenCalled(); expect(request3).toHaveBeenCalled(); }); }); }); +}); - describe('countChanged event', () => { - it('gets emitted when the queue length changes', async () => { - const options: QueuedRequestControllerOptions = { - messenger: buildQueuedRequestControllerMessenger(), - }; - - const controller = new QueuedRequestController(options); - - // Mock the event listener - const eventListener = jest.fn(); - - // Subscribe to the countChanged event - options.messenger.subscribe( - 'QueuedRequestController:countChanged', - eventListener, - ); - - // Enqueue a request, which should increase the count - controller.enqueueRequest( - async () => new Promise((resolve) => setTimeout(resolve, 10)), - ); - expect(eventListener).toHaveBeenNthCalledWith(1, 1); - - // Enqueue another request, which should increase the count - controller.enqueueRequest( - async () => new Promise((resolve) => setTimeout(resolve, 10)), - ); - expect(eventListener).toHaveBeenNthCalledWith(2, 2); - - // Resolve the first request, which should decrease the count - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(eventListener).toHaveBeenNthCalledWith(3, 1); - - // Resolve the second request, which should decrease the count - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(eventListener).toHaveBeenNthCalledWith(4, 0); - }); - }); - - describe('length', () => { - it('returns the correct queue length', async () => { - const options: QueuedRequestControllerOptions = { - messenger: buildQueuedRequestControllerMessenger(), - }; - - const controller = new QueuedRequestController(options); - - // Initially, the queue length should be 0 - expect(controller.length()).toBe(0); - - const promise = controller.enqueueRequest(async () => { - expect(controller.length()).toBe(1); - return Promise.resolve(); - }); - expect(controller.length()).toBe(1); - await promise; - expect(controller.length()).toBe(0); +/** + * Build a controller messenger setup with QueuedRequestController types. + * + * @param options - Options + * @param options.networkControllerGetNetworkConfigurationByNetworkClientId - A handler for the + * `NetworkController:getNetworkConfigurationByNetworkClientId` action. + * @param options.networkControllerGetState - A handler for the `NetworkController:getState` + * action. + * @param options.networkControllerSetActiveNetwork - A handler for the + * `NetworkController:setActiveNetwork` action. + * @param options.approvalControllerAddRequest - A handler for the `ApprovalController:addRequest` + * action. + * @returns A controller messenger with QueuedRequestController types, and + * mocks for all allowed actions. + */ +function buildControllerMessenger({ + networkControllerGetNetworkConfigurationByNetworkClientId, + networkControllerGetState, + networkControllerSetActiveNetwork, + approvalControllerAddRequest, +}: { + networkControllerGetNetworkConfigurationByNetworkClientId?: NetworkControllerGetNetworkConfigurationByNetworkClientId['handler']; + networkControllerGetState?: NetworkControllerGetStateAction['handler']; + networkControllerSetActiveNetwork?: NetworkControllerSetActiveNetworkAction['handler']; + approvalControllerAddRequest?: AddApprovalRequest['handler']; +} = {}): { + messenger: ControllerMessenger< + QueuedRequestControllerActions | AllowedActions, + QueuedRequestControllerEvents + >; + mockNetworkControllerGetNetworkConfigurationByNetworkClientId: jest.Mocked< + NetworkControllerGetNetworkConfigurationByNetworkClientId['handler'] + >; + mockNetworkControllerGetState: jest.Mocked< + NetworkControllerGetStateAction['handler'] + >; + mockNetworkControllerSetActiveNetwork: jest.Mocked< + NetworkControllerSetActiveNetworkAction['handler'] + >; + mockApprovalControllerAddRequest: jest.Mocked; +} { + const messenger = new ControllerMessenger< + QueuedRequestControllerActions | AllowedActions, + QueuedRequestControllerEvents + >(); + + const mockNetworkControllerGetNetworkConfigurationByNetworkClientId = + networkControllerGetNetworkConfigurationByNetworkClientId ?? + jest.fn().mockReturnValue({}); + messenger.registerActionHandler( + 'NetworkController:getNetworkConfigurationByNetworkClientId', + mockNetworkControllerGetNetworkConfigurationByNetworkClientId, + ); + const mockNetworkControllerGetState = + networkControllerGetState ?? + jest.fn().mockReturnValue({ + ...cloneDeep(defaultNetworkState), + selectedNetworkClientId: 'defaultNetworkClientId', }); + messenger.registerActionHandler( + 'NetworkController:getState', + mockNetworkControllerGetState, + ); + const mockNetworkControllerSetActiveNetwork = + networkControllerSetActiveNetwork ?? jest.fn(); + messenger.registerActionHandler( + 'NetworkController:setActiveNetwork', + mockNetworkControllerSetActiveNetwork, + ); + const mockApprovalControllerAddRequest = + approvalControllerAddRequest ?? jest.fn(); + messenger.registerActionHandler( + 'ApprovalController:addRequest', + mockApprovalControllerAddRequest, + ); + return { + messenger, + mockNetworkControllerGetNetworkConfigurationByNetworkClientId, + mockNetworkControllerGetState, + mockNetworkControllerSetActiveNetwork, + mockApprovalControllerAddRequest, + }; +} - it('correctly reflects increasing queue length as requests are enqueued', async () => { - const options: QueuedRequestControllerOptions = { - messenger: buildQueuedRequestControllerMessenger(), - }; - - const controller = new QueuedRequestController(options); - - expect(controller.length()).toBe(0); - - controller.enqueueRequest(async () => { - expect(controller.length()).toBe(1); - return Promise.resolve(); - }); - expect(controller.length()).toBe(1); - - const req2 = controller.enqueueRequest(async () => { - expect(controller.length()).toBe(2); - return Promise.resolve(); - }); - expect(controller.length()).toBe(2); - - const req3 = controller.enqueueRequest(async () => { - // if we dont wait for the outter enqueueRequest to be complete, the count might not be updated when by the time this nextTick occurs. - await req2; - expect(controller.length()).toBe(1); - return Promise.resolve(); - }); - - expect(controller.length()).toBe(3); - await req3; - expect(controller.length()).toBe(0); - }); +/** + * Builds a restricted controller messenger for the queued request controller. + * + * @param messenger - A controller messenger. + * @returns The restricted controller messenger. + */ +function buildQueuedRequestControllerMessenger( + messenger = buildControllerMessenger().messenger, +): QueuedRequestControllerMessenger { + return messenger.getRestricted({ + name: controllerName, + allowedActions: [ + 'NetworkController:getState', + 'NetworkController:setActiveNetwork', + 'NetworkController:getNetworkConfigurationByNetworkClientId', + 'ApprovalController:addRequest', + ], }); -}); +} + +/** + * Build a valid JSON-RPC request that includes all required properties + * + * @returns A valid JSON-RPC request with all required properties. + */ +function buildRequest(): QueuedRequestMiddlewareJsonRpcRequest { + return { + method: 'doesnt matter', + id: 'doesnt matter', + jsonrpc: '2.0' as const, + origin: 'example.com', + networkClientId: 'mainnet', + }; +} diff --git a/packages/queued-request-controller/src/QueuedRequestController.ts b/packages/queued-request-controller/src/QueuedRequestController.ts index aa32476156..e2085bbff4 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.ts @@ -1,39 +1,68 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { AddApprovalRequest } from '@metamask/approval-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import { ApprovalType } from '@metamask/controller-utils'; +import type { + NetworkControllerGetNetworkConfigurationByNetworkClientId, + NetworkControllerGetStateAction, + NetworkControllerSetActiveNetworkAction, +} from '@metamask/network-controller'; + +import type { QueuedRequestMiddlewareJsonRpcRequest } from './types'; export const controllerName = 'QueuedRequestController'; -export const QueuedRequestControllerActionTypes = { - enqueueRequest: `${controllerName}:enqueueRequest` as const, +export type QueuedRequestControllerState = { + queuedRequestCount: number; }; -export const QueuedRequestControllerEventTypes = { - countChanged: `${controllerName}:countChanged` as const, +export const QueuedRequestControllerActionTypes = { + enqueueRequest: `${controllerName}:enqueueRequest` as const, + getState: `${controllerName}:getState` as const, }; -export type QueuedRequestControllerState = Record; - -export type QueuedRequestControllerCountChangedEvent = { - type: typeof QueuedRequestControllerEventTypes.countChanged; - payload: [number]; -}; +export type QueuedRequestControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + QueuedRequestControllerState +>; export type QueuedRequestControllerEnqueueRequestAction = { type: typeof QueuedRequestControllerActionTypes.enqueueRequest; handler: QueuedRequestController['enqueueRequest']; }; +export const QueuedRequestControllerEventTypes = { + stateChange: `${controllerName}:stateChange` as const, +}; + +export type QueuedRequestControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + QueuedRequestControllerState + >; + export type QueuedRequestControllerEvents = - QueuedRequestControllerCountChangedEvent; + QueuedRequestControllerStateChangeEvent; export type QueuedRequestControllerActions = - QueuedRequestControllerEnqueueRequestAction; + | QueuedRequestControllerGetStateAction + | QueuedRequestControllerEnqueueRequestAction; + +export type AllowedActions = + | NetworkControllerGetStateAction + | NetworkControllerSetActiveNetworkAction + | NetworkControllerGetNetworkConfigurationByNetworkClientId + | AddApprovalRequest; export type QueuedRequestControllerMessenger = RestrictedControllerMessenger< typeof controllerName, - QueuedRequestControllerActions, + QueuedRequestControllerActions | AllowedActions, QueuedRequestControllerEvents, - never, + AllowedActions['type'], never >; @@ -60,8 +89,6 @@ export class QueuedRequestController extends BaseController< > { private currentRequest: Promise = Promise.resolve(); - #count = 0; - /** * Constructs a QueuedRequestController, responsible for managing and processing enqueued requests sequentially. * @param options - The controller options, including the restricted controller messenger for the QueuedRequestController. @@ -70,9 +97,14 @@ export class QueuedRequestController extends BaseController< constructor({ messenger }: QueuedRequestControllerOptions) { super({ name: controllerName, - metadata: {}, + metadata: { + queuedRequestCount: { + anonymous: true, + persist: false, + }, + }, messenger, - state: {}, + state: { queuedRequestCount: 0 }, }); this.#registerMessageHandlers(); } @@ -85,23 +117,65 @@ export class QueuedRequestController extends BaseController< } /** - * Gets the current count of enqueued requests in the request queue. This count represents the number of - * pending requests that are waiting to be processed sequentially. + * Switch the current globally selected network if necessary for processing the given + * request. * - * @returns The current count of enqueued requests. This count reflects the number of pending - * requests in the queue, which are yet to be processed. It allows you to monitor the queue's workload - * and assess the volume of requests awaiting execution. + * @param request - The request currently being processed. + * @throws Throws an error if the current selected `networkClientId` or the + * `networkClientId` on the request are invalid. */ - length() { - return this.#count; + async #switchNetworkIfNecessary( + request: QueuedRequestMiddlewareJsonRpcRequest, + ) { + const { selectedNetworkClientId } = this.messagingSystem.call( + 'NetworkController:getState', + ); + if (request.networkClientId === selectedNetworkClientId) { + return; + } + + const toNetworkConfiguration = this.messagingSystem.call( + 'NetworkController:getNetworkConfigurationByNetworkClientId', + request.networkClientId, + ); + const fromNetworkConfiguration = this.messagingSystem.call( + 'NetworkController:getNetworkConfigurationByNetworkClientId', + selectedNetworkClientId, + ); + if (!toNetworkConfiguration) { + throw new Error( + `Missing network configuration for ${request.networkClientId}`, + ); + } else if (!fromNetworkConfiguration) { + throw new Error( + `Missing network configuration for ${selectedNetworkClientId}`, + ); + } + + const requestData = { + toNetworkConfiguration, + fromNetworkConfiguration, + }; + await this.messagingSystem.call( + 'ApprovalController:addRequest', + { + origin: request.origin, + type: ApprovalType.SwitchEthereumChain, + requestData, + }, + true, + ); + + await this.messagingSystem.call( + 'NetworkController:setActiveNetwork', + request.networkClientId, + ); } #updateCount(change: -1 | 1) { - this.#count += change; - this.messagingSystem.publish( - 'QueuedRequestController:countChanged', - this.#count, - ); + this.update((state) => { + state.queuedRequestCount += change; + }); } /** @@ -109,29 +183,46 @@ export class QueuedRequestController extends BaseController< * requests, ensuring they are executed one after the other to prevent concurrency issues and maintain proper * execution flow. * - * @param requestNext - A function representing the request to be enqueued. It returns a promise that + * @param request - The JSON-RPC request to process. + * @param requestNext - A function representing the next steps for processing this request. It returns a promise that * resolves when the request is complete. * @returns A promise that resolves when the enqueued request and any subsequent asynchronous * operations are fully processed. This allows you to await the completion of the enqueued request before continuing * with additional actions. If there are multiple enqueued requests, this function ensures they are processed in * the order they were enqueued, guaranteeing sequential execution. */ - async enqueueRequest(requestNext: (...arg: unknown[]) => Promise) { + async enqueueRequest( + request: QueuedRequestMiddlewareJsonRpcRequest, + requestNext: () => Promise, + ) { this.#updateCount(1); - - if (this.#count > 1) { - await this.currentRequest; + if (this.state.queuedRequestCount > 1) { + try { + await this.currentRequest; + } catch (_error) { + // error ignored - this is handled in the middleware instead + this.#updateCount(-1); + } } - this.currentRequest = requestNext() - .then(() => { - this.#updateCount(-1); - }) - .catch((e) => { + const processCurrentRequest = async () => { + try { + if ( + request.method !== 'wallet_switchEthereumChain' && + request.method !== 'wallet_addEthereumChain' + ) { + await this.#switchNetworkIfNecessary(request); + } + + await requestNext(); + } finally { + // The count is updated as part of the request processing to ensure + // that it has been updated before the next request is run. this.#updateCount(-1); - throw e; - }); + } + }; + this.currentRequest = processCurrentRequest(); await this.currentRequest; } } diff --git a/packages/queued-request-controller/src/QueuedRequestMiddleware.test.ts b/packages/queued-request-controller/src/QueuedRequestMiddleware.test.ts index 35e54a7607..d5f399c86e 100644 --- a/packages/queued-request-controller/src/QueuedRequestMiddleware.test.ts +++ b/packages/queued-request-controller/src/QueuedRequestMiddleware.test.ts @@ -1,607 +1,277 @@ -import type { ApprovalController } from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; -import { NetworkType } from '@metamask/controller-utils'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import type { - NetworkController, - NetworkControllerGetStateAction, - ProviderConfig, -} from '@metamask/network-controller'; -import { defaultState as networkControllerDefaultState } from '@metamask/network-controller'; -import { serializeError } from '@metamask/rpc-errors'; -import { SelectedNetworkControllerActionTypes } from '@metamask/selected-network-controller'; +import { errorCodes } from '@metamask/rpc-errors'; import type { Json, PendingJsonRpcResponse } from '@metamask/utils'; -import type { QueuedRequestMiddlewareMessenger } from './QueuedRequestMiddleware'; -import { - createQueuedRequestMiddleware, - type QueuedRequestMiddlewareJsonRpcRequest, -} from './QueuedRequestMiddleware'; - -/** - * Build a controller messenger that includes all actions and events used by the queued request controller middleware. - * - * @returns The controller messenger. - */ -function buildMessenger(): QueuedRequestMiddlewareMessenger { - return new ControllerMessenger(); -} - -const buildMocks = ( - messenger: QueuedRequestMiddlewareMessenger, - mocks: { - getNetworkClientById?: NetworkController['getNetworkClientById']; - getProviderConfig?: () => ProviderConfig; - addRequest?: ApprovalController['add']; - // since NetworkConfigurations is not exported, we get it this way. Todo: export the type or expose a getter on NetworkController - getNetworkConfigurations?: () => ReturnType< - NetworkControllerGetStateAction['handler'] - >['networkConfigurations']; - } = {}, -) => { - const mockGetNetworkClientById = - mocks.getNetworkClientById ?? - jest.fn().mockReturnValue({ - configuration: { - chainId: '0x1', - }, - }); - messenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - mockGetNetworkClientById, - ); - - const mockGetNetworkConfigurations = - mocks.getNetworkConfigurations ?? jest.fn(() => ({})); - const mockGetProviderConfig = - mocks.getProviderConfig ?? - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - jest.fn(() => ({ - chainId: '0x1', - type: NetworkType.mainnet, - ticker: 'ETH', - })); - const mockGetNetworkControllerState = jest.fn(() => ({ - ...networkControllerDefaultState, - networkConfigurations: mockGetNetworkConfigurations(), - providerConfig: mockGetProviderConfig(), - })); - - messenger.registerActionHandler( - 'NetworkController:getState', - mockGetNetworkControllerState, - ); - - const mockEnqueueRequest = jest.fn().mockImplementation((cb) => cb()); - messenger.registerActionHandler( - 'QueuedRequestController:enqueueRequest', - mockEnqueueRequest, - ); - - const mockAddRequest = mocks.addRequest ?? jest.fn().mockResolvedValue(true); - messenger.registerActionHandler( - 'ApprovalController:addRequest', - mockAddRequest, - ); - - const mockSetActiveNetwork = jest.fn().mockResolvedValue(true); - messenger.registerActionHandler( - 'NetworkController:setActiveNetwork', - mockSetActiveNetwork, - ); - - const mockSetNetworkClientIdForDomain = jest.fn().mockResolvedValue(true); - messenger.registerActionHandler( - SelectedNetworkControllerActionTypes.setNetworkClientIdForDomain, - mockSetNetworkClientIdForDomain, - ); +import type { QueuedRequestControllerEnqueueRequestAction } from './QueuedRequestController'; +import { createQueuedRequestMiddleware } from './QueuedRequestMiddleware'; +import type { QueuedRequestMiddlewareJsonRpcRequest } from './types'; +const getRequestDefaults = (): QueuedRequestMiddlewareJsonRpcRequest => { return { - getProviderConfig: mockGetProviderConfig, - getNetworkConfigurations: mockGetNetworkConfigurations, - getNetworkControllerState: mockGetNetworkControllerState, - getNetworkClientById: mockGetNetworkClientById, - enqueueRequest: mockEnqueueRequest, - addRequest: mockAddRequest, - setActiveNetwork: mockSetActiveNetwork, - setNetworkClientIdForDomain: mockSetNetworkClientIdForDomain, + method: 'doesnt matter', + id: 'doesnt matter', + jsonrpc: '2.0' as const, + origin: 'example.com', + networkClientId: 'mainnet', }; }; -const requestDefaults = { - method: 'doesnt matter', - id: 'doesnt matter', - jsonrpc: '2.0' as const, - origin: 'example.com', - networkClientId: 'mainnet', +const getPendingResponseDefault = (): PendingJsonRpcResponse => { + return { + id: 'doesnt matter', + jsonrpc: '2.0' as const, + }; }; +const getMockEnqueueRequest = () => + jest + .fn< + ReturnType, + Parameters + >() + .mockImplementation((_origin, requestNext) => requestNext()); + describe('createQueuedRequestMiddleware', () => { it('throws if not provided an origin', async () => { - const messenger = buildMessenger(); const middleware = createQueuedRequestMiddleware({ - messenger, + enqueueRequest: getMockEnqueueRequest(), useRequestQueue: () => false, }); - const req: QueuedRequestMiddlewareJsonRpcRequest = { - id: '123', - jsonrpc: '2.0', - method: 'anything', - networkClientId: 'anything', - }; + const request = getRequestDefaults(); + // @ts-expect-error Intentionally invalid request + delete request.origin; await expect( () => new Promise((resolve, reject) => - middleware( - req, - {} as PendingJsonRpcResponse, - resolve, - reject, - ), + middleware(request, getPendingResponseDefault(), resolve, reject), ), ).rejects.toThrow("Request object is lacking an 'origin'"); }); + it('throws if provided an invalid origin', async () => { + const middleware = createQueuedRequestMiddleware({ + enqueueRequest: getMockEnqueueRequest(), + useRequestQueue: () => false, + }); + const request = getRequestDefaults(); + // @ts-expect-error Intentionally invalid request + request.origin = 1; + + await expect( + () => + new Promise((resolve, reject) => + middleware(request, getPendingResponseDefault(), resolve, reject), + ), + ).rejects.toThrow("Request object has an invalid origin of type 'number'"); + }); + it('throws if not provided an networkClientId', async () => { - const messenger = buildMessenger(); const middleware = createQueuedRequestMiddleware({ - messenger, + enqueueRequest: getMockEnqueueRequest(), useRequestQueue: () => false, }); - const req: QueuedRequestMiddlewareJsonRpcRequest = { - id: '123', - jsonrpc: '2.0', - method: 'anything', - origin: 'anything', - }; + const request = getRequestDefaults(); + // @ts-expect-error Intentionally invalid request + delete request.networkClientId; await expect( () => new Promise((resolve, reject) => - middleware( - req, - {} as PendingJsonRpcResponse, - resolve, - reject, - ), + middleware(request, getPendingResponseDefault(), resolve, reject), ), ).rejects.toThrow("Request object is lacking a 'networkClientId'"); }); - it('should not enqueue the request when useRequestQueue is false', async () => { - const messenger = buildMessenger(); + it('throws if provided an invalid networkClientId', async () => { + const middleware = createQueuedRequestMiddleware({ + enqueueRequest: getMockEnqueueRequest(), + useRequestQueue: () => false, + }); + const request = getRequestDefaults(); + // @ts-expect-error Intentionally invalid request + request.networkClientId = 1; + + await expect( + () => + new Promise((resolve, reject) => + middleware(request, getPendingResponseDefault(), resolve, reject), + ), + ).rejects.toThrow( + "Request object has an invalid networkClientId of type 'number'", + ); + }); + + it('does not enqueue the request when useRequestQueue is false', async () => { + const mockEnqueueRequest = getMockEnqueueRequest(); const middleware = createQueuedRequestMiddleware({ - messenger, + enqueueRequest: mockEnqueueRequest, useRequestQueue: () => false, }); - const mocks = buildMocks(messenger); await new Promise((resolve, reject) => middleware( - { ...requestDefaults }, - {} as PendingJsonRpcResponse, + getRequestDefaults(), + getPendingResponseDefault(), resolve, reject, ), ); - expect(mocks.enqueueRequest).not.toHaveBeenCalled(); + expect(mockEnqueueRequest).not.toHaveBeenCalled(); }); - it('should not enqueue the request when there is no confirmation', async () => { - const messenger = buildMessenger(); + it('does not enqueue request that has no confirmation', async () => { + const mockEnqueueRequest = getMockEnqueueRequest(); const middleware = createQueuedRequestMiddleware({ - messenger, + enqueueRequest: mockEnqueueRequest, useRequestQueue: () => true, }); - const mocks = buildMocks(messenger); - const req = { - ...requestDefaults, + const request = { + ...getRequestDefaults(), method: 'eth_chainId', }; await new Promise((resolve, reject) => - middleware( - req, - {} as PendingJsonRpcResponse, - resolve, - reject, - ), + middleware(request, getPendingResponseDefault(), resolve, reject), ); - expect(mocks.enqueueRequest).not.toHaveBeenCalled(); + expect(mockEnqueueRequest).not.toHaveBeenCalled(); }); - describe('confirmations', () => { - it('should resolve requests that require confirmations for infura networks', async () => { - const messenger = buildMessenger(); - const middleware = createQueuedRequestMiddleware({ - messenger, - useRequestQueue: () => true, - }); - const mocks = buildMocks(messenger); + it('enqueues request that has a confirmation', async () => { + const mockEnqueueRequest = getMockEnqueueRequest(); + const middleware = createQueuedRequestMiddleware({ + enqueueRequest: mockEnqueueRequest, + useRequestQueue: () => true, + }); + const request = { + ...getRequestDefaults(), + origin: 'exampleorigin.com', + method: 'eth_sendTransaction', + }; - const req = { - ...requestDefaults, - method: 'eth_sendTransaction', - }; + await new Promise((resolve, reject) => + middleware(request, getPendingResponseDefault(), resolve, reject), + ); - await new Promise((resolve, reject) => - middleware( - req, - {} as PendingJsonRpcResponse, - resolve, - reject, - ), - ); + expect(mockEnqueueRequest).toHaveBeenCalledWith( + request, + expect.any(Function), + ); + }); - expect(mocks.enqueueRequest).toHaveBeenCalled(); - expect(mocks.getNetworkClientById).toHaveBeenCalledWith('mainnet'); + it('enqueues request that have a confirmation', async () => { + const mockEnqueueRequest = getMockEnqueueRequest(); + const middleware = createQueuedRequestMiddleware({ + enqueueRequest: mockEnqueueRequest, + useRequestQueue: () => true, }); + const request = { + ...getRequestDefaults(), + origin: 'exampleorigin.com', + method: 'eth_sendTransaction', + }; - it('should resolve requests that require confirmations for custom networks', async () => { - const messenger = buildMessenger(); - const middleware = createQueuedRequestMiddleware({ - messenger, - useRequestQueue: () => true, - }); - const networkClientId = '12309-12039-12309'; - const mocks = buildMocks(messenger, { - getNetworkConfigurations: jest.fn(() => ({ - [networkClientId]: { - id: networkClientId, - rpcUrl: 'foo.com', - ticker: 'foo', - chainId: '0x123', - }, - })), - }); - - const req = { - ...requestDefaults, - networkClientId, - method: 'eth_sendTransaction', - }; + await new Promise((resolve, reject) => + middleware(request, getPendingResponseDefault(), resolve, reject), + ); - await new Promise((resolve, reject) => - middleware( - req, - {} as PendingJsonRpcResponse, - resolve, - reject, - ), - ); + expect(mockEnqueueRequest).toHaveBeenCalledWith( + request, + expect.any(Function), + ); + }); - expect(mocks.enqueueRequest).toHaveBeenCalled(); - // custom networks use getNetworkClientyId - expect(mocks.getNetworkClientById).toHaveBeenCalledWith(networkClientId); + it('calls next when a request is not queued', async () => { + const middleware = createQueuedRequestMiddleware({ + enqueueRequest: getMockEnqueueRequest(), + useRequestQueue: () => false, }); + const mockNext = jest.fn(); - it('switchEthereumChain calls get queued but we dont check the current network', async () => { - const messenger = buildMessenger(); - const middleware = createQueuedRequestMiddleware({ - messenger, - useRequestQueue: () => true, - }); - const mocks = buildMocks(messenger); - - const req = { - ...requestDefaults, - method: 'wallet_switchEthereumChain', - }; - - await new Promise((resolve, reject) => - middleware( - req, - {} as PendingJsonRpcResponse, - resolve, - reject, - ), + await new Promise((resolve) => { + mockNext.mockImplementation(resolve); + middleware( + getRequestDefaults(), + getPendingResponseDefault(), + mockNext, + jest.fn(), ); - - expect(mocks.addRequest).not.toHaveBeenCalled(); - expect(mocks.enqueueRequest).toHaveBeenCalled(); - expect(mocks.getProviderConfig).not.toHaveBeenCalled(); }); - describe('requiring switch', () => { - it('calls addRequest to switchEthChain if the current network is different than the globally selected network', async () => { - const messenger = buildMessenger(); - const middleware = createQueuedRequestMiddleware({ - messenger, - useRequestQueue: () => true, - }); - const mockGetProviderConfig = jest.fn().mockReturnValue({ - chainId: '0x5', - }); - const mocks = buildMocks(messenger, { - getProviderConfig: mockGetProviderConfig, - }); - - const req = { - ...requestDefaults, // chainId = '0x1' - method: 'eth_sendTransaction', - }; - - await new Promise((resolve, reject) => - middleware( - req, - {} as PendingJsonRpcResponse, - resolve, - reject, - ), - ); - - expect(mocks.addRequest).toHaveBeenCalled(); - expect(mocks.enqueueRequest).toHaveBeenCalled(); - expect(mocks.setNetworkClientIdForDomain).toHaveBeenCalled(); - }); + expect(mockNext).toHaveBeenCalled(); + }); - it('if the switchEthConfirmation is rejected, the original request is rejected', async () => { - const messenger = buildMessenger(); - const middleware = createQueuedRequestMiddleware({ - messenger, - useRequestQueue: () => true, - }); - const rejected = new Error('big bad rejected'); - const mockAddRequest = jest.fn().mockRejectedValue(rejected); - const mockGetProviderConfig = jest.fn().mockReturnValue({ - chainId: '0x5', - }); - const mocks = buildMocks(messenger, { - addRequest: mockAddRequest, - getProviderConfig: mockGetProviderConfig, - }); - - const req = { - ...requestDefaults, - method: 'eth_sendTransaction', - }; - - const res = {} as PendingJsonRpcResponse; - await new Promise((resolve, reject) => - middleware(req, res, reject, resolve), - ); - - expect(mocks.addRequest).toHaveBeenCalled(); - expect(mocks.enqueueRequest).toHaveBeenCalled(); - expect(mocks.setNetworkClientIdForDomain).not.toHaveBeenCalled(); - expect(res.error).toStrictEqual(serializeError(rejected)); - }); + it('calls next after a request is queued and processed', async () => { + const middleware = createQueuedRequestMiddleware({ + enqueueRequest: getMockEnqueueRequest(), + useRequestQueue: () => true, + }); + const request = { + ...getRequestDefaults(), + method: 'eth_sendTransaction', + }; + const mockNext = jest.fn(); - it('switches the current active network', async () => { - const messenger = buildMessenger(); - const middleware = createQueuedRequestMiddleware({ - messenger, - useRequestQueue: () => true, - }); - const networkClientId = '123123-123123-123123'; - const mocks = buildMocks(messenger, { - getNetworkConfigurations: jest.fn(() => ({ - [networkClientId]: { - id: networkClientId, - rpcUrl: 'foo.com', - ticker: 'foo', - chainId: '0x123', - }, - })), - getProviderConfig: jest.fn().mockReturnValue({ - chainId: '0x1', - }), - getNetworkClientById: jest.fn().mockReturnValue({ - configuration: { - chainId: '0x123', - }, - }), - }); - - const req = { - ...requestDefaults, - origin: 'example.com', - method: 'eth_sendTransaction', - networkClientId, - }; - - await new Promise((resolve, reject) => - middleware( - req, - {} as PendingJsonRpcResponse, - resolve, - reject, - ), - ); - - expect(mocks.setActiveNetwork).toHaveBeenCalled(); - }); + await new Promise((resolve) => { + mockNext.mockImplementation(resolve); + middleware(request, getPendingResponseDefault(), mockNext, jest.fn()); }); + + expect(mockNext).toHaveBeenCalled(); }); - describe('concurrent requests', () => { - it('rejecting one call does not cause others to be rejected', async () => { - const messenger = buildMessenger(); + describe('when enqueueRequest throws', () => { + it('ends without calling next', async () => { const middleware = createQueuedRequestMiddleware({ - messenger, + enqueueRequest: jest + .fn() + .mockRejectedValue(new Error('enqueuing error')), useRequestQueue: () => true, }); - const rejectedError = new Error('big bad rejected'); - const mockAddRequest = jest - .fn() - .mockRejectedValueOnce(rejectedError) - .mockResolvedValueOnce(true); - - const mockGetProviderConfig = jest.fn().mockReturnValue({ - chainId: '0x5', - }); - - const mocks = buildMocks(messenger, { - addRequest: mockAddRequest, - getProviderConfig: mockGetProviderConfig, - }); - - const req1 = { - ...requestDefaults, - origin: 'example.com', - method: 'eth_sendTransaction', - }; - - const req2 = { - ...requestDefaults, - origin: 'example.com', + const request = { + ...getRequestDefaults(), method: 'eth_sendTransaction', }; + const mockNext = jest.fn(); + const mockEnd = jest.fn(); - const res1 = {} as PendingJsonRpcResponse; - const res2 = {} as PendingJsonRpcResponse; - - await Promise.all([ - new Promise((resolve) => middleware(req1, res1, resolve, resolve)), - new Promise((resolve) => middleware(req2, res2, resolve, resolve)), - ]); - - expect(mocks.addRequest).toHaveBeenCalledTimes(2); - expect(res1.error).toStrictEqual(serializeError(rejectedError)); - expect(res2.error).toBeUndefined(); - }); - }); - - describe('integration', () => { - it('does not queue requests that lack confirmations', async () => { - const engine = new JsonRpcEngine(); - const messenger = buildMessenger(); - const mocks = buildMocks(messenger); - engine.push((req: QueuedRequestMiddlewareJsonRpcRequest, _, next) => { - req.origin = 'foobar'; - req.networkClientId = 'mainnet'; - next(); + await new Promise((resolve) => { + mockEnd.mockImplementation(resolve); + middleware(request, getPendingResponseDefault(), mockNext, mockEnd); }); - engine.push( - createQueuedRequestMiddleware({ - messenger, - useRequestQueue: () => true, - }), - ); - const mockNextMiddleware = jest - .fn() - .mockImplementation((_, res, __, end) => { - res.result = true; - end(); - }); - engine.push(mockNextMiddleware); - const result = await engine.handle({ - id: 1, - jsonrpc: '2.0', - method: 'foo', - params: [], - }); - expect(result).toStrictEqual(expect.objectContaining({ result: true })); - expect(mocks.enqueueRequest).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + expect(mockEnd).toHaveBeenCalled(); }); - it('queues requests that require confirmation', async () => { - const engine = new JsonRpcEngine(); - const messenger = buildMessenger(); - const mocks = buildMocks(messenger); - engine.push((req: QueuedRequestMiddlewareJsonRpcRequest, _, next) => { - req.origin = 'foobar'; - req.networkClientId = 'mainnet'; - next(); + it('serializes processing errors and attaches them to the response', async () => { + const middleware = createQueuedRequestMiddleware({ + enqueueRequest: jest + .fn() + .mockRejectedValue(new Error('enqueuing error')), + useRequestQueue: () => true, }); - engine.push( - createQueuedRequestMiddleware({ - messenger, - useRequestQueue: () => true, - }), - ); - - const mockNextMiddleware = jest - .fn() - .mockImplementation((_, res, __, end) => { - res.result = true; - end(); - }); - engine.push(mockNextMiddleware); - const result = await engine.handle({ - id: 1, - jsonrpc: '2.0', + const request = { + ...getRequestDefaults(), method: 'eth_sendTransaction', - params: [], - }); - expect(result).toStrictEqual(expect.objectContaining({ result: true })); - expect(mocks.enqueueRequest).toHaveBeenCalled(); - }); + }; + const response = getPendingResponseDefault(); - it('one request being rejected does not reject the following', async () => { - const engine = new JsonRpcEngine(); - const messenger = buildMessenger(); - const mocks = buildMocks(messenger); - engine.push((req: QueuedRequestMiddlewareJsonRpcRequest, _, next) => { - req.origin = 'foobar'; - req.networkClientId = 'mainnet'; - next(); - }); - engine.push( - createQueuedRequestMiddleware({ - messenger, - useRequestQueue: () => true, - }), + await new Promise((resolve) => + middleware(request, response, jest.fn(), resolve), ); - const ordering: number[] = []; - const mockNextMiddleware = jest - .fn() - .mockImplementationOnce(async (req, res, _, end) => { - res.error = new Error('user has rejected blah blah'); - await new Promise((resolve) => setTimeout(resolve, 5)); - ordering.push(req.id); - end(); - }) - .mockImplementationOnce((req, res, _, end) => { - res.result = true; - ordering.push(req.id); - end(); - }) - .mockImplementationOnce(async (req, res, _, end) => { - res.result = true; - await new Promise((resolve) => setTimeout(resolve, 5)); - ordering.push(req.id); - end(); - }); - engine.push(mockNextMiddleware); - const [first, second, third] = await Promise.all([ - engine.handle({ - id: 1, - jsonrpc: '2.0', - method: 'eth_sendTransaction', - params: [], - }), - engine.handle({ - id: 2, - jsonrpc: '2.0', - method: 'not_queued', - params: [], - }), - engine.handle({ - id: 3, - jsonrpc: '2.0', - method: 'eth_sendTransaction', - params: [], - }), - ]); - expect(first).toStrictEqual( - expect.objectContaining({ - error: expect.objectContaining({ - message: 'Internal JSON-RPC error.', - }), - }), - ); - expect(second).toStrictEqual(expect.objectContaining({ result: true })); - expect(third).toStrictEqual(expect.objectContaining({ result: true })); - expect(ordering).toStrictEqual([2, 1, 3]); // 1 should be first because its not queued. - expect(mocks.enqueueRequest).toHaveBeenCalled(); + expect(response.error).toMatchObject({ + code: errorCodes.rpc.internal, + data: { + cause: { + message: 'enqueuing error', + stack: expect.any(String), + }, + }, + }); }); }); }); diff --git a/packages/queued-request-controller/src/QueuedRequestMiddleware.ts b/packages/queued-request-controller/src/QueuedRequestMiddleware.ts index c143eb9927..e0a1f988fa 100644 --- a/packages/queued-request-controller/src/QueuedRequestMiddleware.ts +++ b/packages/queued-request-controller/src/QueuedRequestMiddleware.ts @@ -1,40 +1,10 @@ -import type { AddApprovalRequest } from '@metamask/approval-controller'; -import type { ControllerMessenger } from '@metamask/base-controller'; -import { ApprovalType, isNetworkType } from '@metamask/controller-utils'; import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; -import type { - NetworkClientId, - NetworkControllerFindNetworkClientIdByChainIdAction, - NetworkControllerGetNetworkClientByIdAction, - NetworkControllerGetStateAction, - NetworkControllerSetActiveNetworkAction, -} from '@metamask/network-controller'; import { serializeError } from '@metamask/rpc-errors'; -import type { SelectedNetworkControllerSetNetworkClientIdForDomainAction } from '@metamask/selected-network-controller'; -import { SelectedNetworkControllerActionTypes } from '@metamask/selected-network-controller'; import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; -import type { QueuedRequestControllerEnqueueRequestAction } from './QueuedRequestController'; -import { QueuedRequestControllerActionTypes } from './QueuedRequestController'; - -export type MiddlewareAllowedActions = - | NetworkControllerGetStateAction - | NetworkControllerSetActiveNetworkAction - | NetworkControllerGetNetworkClientByIdAction - | NetworkControllerFindNetworkClientIdByChainIdAction - | SelectedNetworkControllerSetNetworkClientIdForDomainAction - | AddApprovalRequest; - -export type QueuedRequestMiddlewareMessenger = ControllerMessenger< - QueuedRequestControllerEnqueueRequestAction | MiddlewareAllowedActions, - never ->; - -export type QueuedRequestMiddlewareJsonRpcRequest = JsonRpcRequest & { - networkClientId?: NetworkClientId; - origin?: string; -}; +import type { QueuedRequestController } from './QueuedRequestController'; +import type { QueuedRequestMiddlewareJsonRpcRequest } from './types'; const isConfirmationMethod = (method: string) => { const confirmationMethods = [ @@ -53,124 +23,62 @@ const isConfirmationMethod = (method: string) => { return confirmationMethods.includes(method); }; +/** + * Ensure that the incoming request has the additional required request metadata. This metadata + * should be attached to the request earlier in the middleware pipeline. + * + * @param request - The request to check. + * @throws Throws an error if any required metadata is missing. + */ +function hasRequiredMetadata( + request: Record, +): asserts request is QueuedRequestMiddlewareJsonRpcRequest { + if (!request.origin) { + throw new Error("Request object is lacking an 'origin'"); + } else if (typeof request.origin !== 'string') { + throw new Error( + `Request object has an invalid origin of type '${typeof request.origin}'`, + ); + } else if (!request.networkClientId) { + throw new Error("Request object is lacking a 'networkClientId'"); + } else if (typeof request.networkClientId !== 'string') { + throw new Error( + `Request object has an invalid networkClientId of type '${typeof request.networkClientId}'`, + ); + } +} + /** * Creates a JSON-RPC middleware for handling queued requests. This middleware * intercepts JSON-RPC requests, checks if they require queueing, and manages * their execution based on the specified options. * * @param options - Configuration options. - * @param options.messenger - A controller messenger used for communication with various controllers. + * @param options.enqueueRequest - A method for enqueueing a request. * @param options.useRequestQueue - A function that determines if the request queue feature is enabled. * @returns The JSON-RPC middleware that manages queued requests. */ export const createQueuedRequestMiddleware = ({ - messenger, + enqueueRequest, useRequestQueue, }: { - messenger: QueuedRequestMiddlewareMessenger; + enqueueRequest: QueuedRequestController['enqueueRequest']; useRequestQueue: () => boolean; }): JsonRpcMiddleware => { - return createAsyncMiddleware( - async (req: QueuedRequestMiddlewareJsonRpcRequest, res, next) => { - const { origin, networkClientId: networkClientIdForRequest } = req; - - if (!origin) { - throw new Error("Request object is lacking an 'origin'"); - } - - if (!networkClientIdForRequest) { - throw new Error("Request object is lacking a 'networkClientId'"); - } - - // if the request queue feature is turned off, or this method is not a confirmation method - // do nothing - if (!useRequestQueue() || !isConfirmationMethod(req.method)) { - next(); - return; - } - - await messenger.call( - QueuedRequestControllerActionTypes.enqueueRequest, - async () => { - if ( - req.method === 'wallet_switchEthereumChain' || - req.method === 'wallet_addEthereumChain' - ) { - return next(); - } - - const networkClientConfigurationForRequest = messenger.call( - 'NetworkController:getNetworkClientById', - networkClientIdForRequest, - ).configuration; - - const networkControllerState = messenger.call( - 'NetworkController:getState', - ); - - const isBuiltIn = isNetworkType(networkClientIdForRequest); - let networkConfigurationForRequest; - if (!isBuiltIn) { - networkConfigurationForRequest = - networkControllerState.networkConfigurations[ - networkClientIdForRequest - ]; - } else { - // if its a built in - // Ideally we should be using only networkConfigurations, and networkClientIds & - // networkConfiguration.id should be the same thing. - networkConfigurationForRequest = - networkClientConfigurationForRequest; - } - - const currentProviderConfig = networkControllerState.providerConfig; - const currentChainId = currentProviderConfig.chainId; - - // if the 'globally selected network' is already on the correct chain for the request currently being processed - // continue with the request as normal. - if (currentChainId === networkConfigurationForRequest.chainId) { - return next(); - } - - // todo once we have 'batches': - // if is switch eth chain call - // clear request queue when the switch ethereum chain call completes (success, but maybe not if it fails?) - // This is because a dapp-requested switch ethereum chain invalidates any requests they've made after this switch, since we dont know if they were expecting the chain after the switch or before. - // with the queue batching approach, this would mean clearing any batch for that origin (batches being per-origin.) - const requestData = { - toNetworkConfiguration: networkConfigurationForRequest, - fromNetworkConfiguration: currentProviderConfig, - }; - - try { - await messenger.call( - 'ApprovalController:addRequest', - { - origin, - type: ApprovalType.SwitchEthereumChain, - requestData, - }, - true, - ); - - await messenger.call( - `NetworkController:setActiveNetwork`, - networkClientIdForRequest, - ); - - messenger.call( - SelectedNetworkControllerActionTypes.setNetworkClientIdForDomain, - origin, - networkClientIdForRequest, - ); - } catch (error) { - res.error = serializeError(error); - return error; - } - - return next(); - }, - ); - }, - ); + return createAsyncMiddleware(async (req: JsonRpcRequest, res, next) => { + hasRequiredMetadata(req); + + // if the request queue feature is turned off, or this method is not a confirmation method + // bypass the queue completely + if (!useRequestQueue() || !isConfirmationMethod(req.method)) { + return await next(); + } + + try { + await enqueueRequest(req, next); + } catch (error: unknown) { + res.error = serializeError(error); + } + return undefined; + }); }; diff --git a/packages/queued-request-controller/src/index.ts b/packages/queued-request-controller/src/index.ts index 27d6cffe09..0461d5694d 100644 --- a/packages/queued-request-controller/src/index.ts +++ b/packages/queued-request-controller/src/index.ts @@ -1,7 +1,8 @@ export type { QueuedRequestControllerState, - QueuedRequestControllerCountChangedEvent, QueuedRequestControllerEnqueueRequestAction, + QueuedRequestControllerGetStateAction, + QueuedRequestControllerStateChangeEvent, QueuedRequestControllerEvents, QueuedRequestControllerActions, QueuedRequestControllerMessenger, @@ -12,5 +13,5 @@ export { QueuedRequestControllerEventTypes, QueuedRequestController, } from './QueuedRequestController'; -export type { QueuedRequestMiddlewareJsonRpcRequest } from './QueuedRequestMiddleware'; +export type { QueuedRequestMiddlewareJsonRpcRequest } from './types'; export { createQueuedRequestMiddleware } from './QueuedRequestMiddleware'; diff --git a/packages/queued-request-controller/src/types.ts b/packages/queued-request-controller/src/types.ts new file mode 100644 index 0000000000..73988976d4 --- /dev/null +++ b/packages/queued-request-controller/src/types.ts @@ -0,0 +1,7 @@ +import type { NetworkClientId } from '@metamask/network-controller'; +import type { JsonRpcRequest } from '@metamask/utils'; + +export type QueuedRequestMiddlewareJsonRpcRequest = JsonRpcRequest & { + networkClientId: NetworkClientId; + origin: string; +}; diff --git a/packages/rate-limit-controller/package.json b/packages/rate-limit-controller/package.json index bc4c0412ec..8cf4bd387d 100644 --- a/packages/rate-limit-controller/package.json +++ b/packages/rate-limit-controller/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@metamask/base-controller": "^4.1.1", - "@metamask/rpc-errors": "^6.1.0" + "@metamask/rpc-errors": "^6.2.1" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/selected-network-controller/CHANGELOG.md b/packages/selected-network-controller/CHANGELOG.md index f0cab584b2..cc5314579c 100644 --- a/packages/selected-network-controller/CHANGELOG.md +++ b/packages/selected-network-controller/CHANGELOG.md @@ -11,6 +11,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +## [8.0.0] + +### Changed + +- **BREAKING:** `setNetworkClientIdForDomain` now throws an error if passed `metamask` for the domain param ([#3908](https://github.com/MetaMask/core/pull/3908)). +- **BREAKING:** `setNetworkClientIdForDomain` now fails and throws an error if the passed in `domain` is not currently permissioned in the `PermissionsController` ([#3908](https://github.com/MetaMask/core/pull/3908)). +- **BREAKING:** the `domains` state now no longer contains a `metamask` domain key. Consumers should instead use the `selectedNetworkClientId` from the `NetworkController` to get the selected network for the `metamask` domain ([#3908](https://github.com/MetaMask/core/pull/3908)). +- **BREAKING:** `getProviderAndBlockTracker` now throws an error if called with any domain while the `perDomainNetwork` flag is false. Consumers should instead use the `provider` and `blockTracker` from the `NetworkController` when the `perDomainNetwork` flag is false ([#3908](https://github.com/MetaMask/core/pull/3908)). +- **BREAKING:** `getProviderAndBlockTracker` now throws an error if called with a domain that does not have a networkClientId set ([#3908](https://github.com/MetaMask/core/pull/3908)). +- **BREAKING:** `getNetworkClientIdForDomain` now returns the `selectedNetworkClientId` for the globally selected network if the `perDomainNetwork` flag is false or if the domain is not in the `domains` state ([#3908](https://github.com/MetaMask/core/pull/3908)). + +### Removed + +- **BREAKING:** Remove logic in `selectedNetworkMiddleware` to set a default `networkClientId` for the requesting origin in the `SelectedNetworkController` when not already set. Now if `networkClientId` is not already set for the requesting origin, the middleware will not set a default `networkClientId` for that origin in the `SelectedNetworkController` but will continue to add the `selectedNetworkClientId` from the `NetworkController` to the `networkClientId` property on the request object ([#3908](https://github.com/MetaMask/core/pull/3908)). + +### Fixed + +- The `SelectedNetworkController` now listens for `networkConfiguration` removal events on the `NetworkController` and updates domains pointed at a removed `networkClientId` to the `selectedNetworkClientId` ([#3926](https://github.com/MetaMask/core/pull/3926)). + ## [7.0.1] ### Changed @@ -110,7 +129,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial Release ([#1643](https://github.com/MetaMask/core/pull/1643)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@7.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@8.0.0...HEAD +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@7.0.1...@metamask/selected-network-controller@8.0.0 [7.0.1]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@7.0.0...@metamask/selected-network-controller@7.0.1 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@6.0.0...@metamask/selected-network-controller@7.0.0 [6.0.0]: https://github.com/MetaMask/core/compare/@metamask/selected-network-controller@5.0.0...@metamask/selected-network-controller@6.0.0 diff --git a/packages/selected-network-controller/package.json b/packages/selected-network-controller/package.json index 3e7be77565..2f0e419844 100644 --- a/packages/selected-network-controller/package.json +++ b/packages/selected-network-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/selected-network-controller", - "version": "7.0.1", + "version": "8.0.0", "description": "Provides an interface to the currently selected networkClientId for a given domain", "keywords": [ "MetaMask", @@ -39,6 +39,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", + "@metamask/permission-controller": "^8.0.1", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "immer": "^9.0.6", diff --git a/packages/selected-network-controller/src/SelectedNetworkController.ts b/packages/selected-network-controller/src/SelectedNetworkController.ts index 38ec02ba78..99f1b33d16 100644 --- a/packages/selected-network-controller/src/SelectedNetworkController.ts +++ b/packages/selected-network-controller/src/SelectedNetworkController.ts @@ -4,9 +4,15 @@ import type { BlockTrackerProxy, NetworkClientId, NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, NetworkControllerStateChangeEvent, ProviderProxy, } from '@metamask/network-controller'; +import type { + PermissionControllerStateChange, + GetSubjects as PermissionControllerGetSubjectsAction, + HasPermissions as PermissionControllerHasPermissions, +} from '@metamask/permission-controller'; import { createEventEmitterProxy } from '@metamask/swappable-obj-proxy'; import type { Patch } from 'immer'; @@ -14,17 +20,13 @@ export const controllerName = 'SelectedNetworkController'; const stateMetadata = { domains: { persist: true, anonymous: false }, - perDomainNetwork: { persist: true, anonymous: false }, }; -const getDefaultState = () => ({ - domains: {}, - perDomainNetwork: false, -}); +const getDefaultState = () => ({ domains: {} }); type Domain = string; -const METAMASK_DOMAIN = 'metamask' as const; +export const METAMASK_DOMAIN = 'metamask' as const; export const SelectedNetworkControllerActionTypes = { getState: `${controllerName}:getState` as const, @@ -40,12 +42,6 @@ export const SelectedNetworkControllerEventTypes = { export type SelectedNetworkControllerState = { domains: Record; - /** - * Feature flag to start returning networkClientId based on the domain. - * when the flag is false, the 'metamask' domain will always be used. - * defaults to false - */ - perDomainNetwork: boolean; }; export type SelectedNetworkControllerStateChangeEvent = { @@ -60,12 +56,12 @@ export type SelectedNetworkControllerGetSelectedNetworkStateAction = { export type SelectedNetworkControllerGetNetworkClientIdForDomainAction = { type: typeof SelectedNetworkControllerActionTypes.getNetworkClientIdForDomain; - handler: (domain: string) => NetworkClientId; + handler: SelectedNetworkController['getNetworkClientIdForDomain']; }; export type SelectedNetworkControllerSetNetworkClientIdForDomainAction = { type: typeof SelectedNetworkControllerActionTypes.setNetworkClientIdForDomain; - handler: (domain: string, NetworkClientId: NetworkClientId) => void; + handler: SelectedNetworkController['setNetworkClientIdForDomain']; }; export type SelectedNetworkControllerActions = @@ -73,12 +69,18 @@ export type SelectedNetworkControllerActions = | SelectedNetworkControllerGetNetworkClientIdForDomainAction | SelectedNetworkControllerSetNetworkClientIdForDomainAction; -export type AllowedActions = NetworkControllerGetNetworkClientByIdAction; +export type AllowedActions = + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetStateAction + | PermissionControllerHasPermissions + | PermissionControllerGetSubjectsAction; export type SelectedNetworkControllerEvents = SelectedNetworkControllerStateChangeEvent; -export type AllowedEvents = NetworkControllerStateChangeEvent; +export type AllowedEvents = + | NetworkControllerStateChangeEvent + | PermissionControllerStateChange; export type SelectedNetworkControllerMessenger = RestrictedControllerMessenger< typeof controllerName, @@ -88,9 +90,12 @@ export type SelectedNetworkControllerMessenger = RestrictedControllerMessenger< AllowedEvents['type'] >; +export type GetUseRequestQueue = () => boolean; + export type SelectedNetworkControllerOptions = { state?: SelectedNetworkControllerState; messenger: SelectedNetworkControllerMessenger; + getUseRequestQueue: GetUseRequestQueue; }; export type NetworkProxy = { @@ -108,16 +113,20 @@ export class SelectedNetworkController extends BaseController< > { #proxies = new Map(); + #getUseRequestQueue: GetUseRequestQueue; + /** * Construct a SelectedNetworkController controller. * * @param options - The controller options. * @param options.messenger - The restricted controller messenger for the EncryptionPublicKey controller. * @param options.state - The controllers initial state. + * @param options.getUseRequestQueue - feature flag for perDappNetwork & request queueing features */ constructor({ messenger, state = getDefaultState(), + getUseRequestQueue, }: SelectedNetworkControllerOptions) { super({ name: controllerName, @@ -125,7 +134,69 @@ export class SelectedNetworkController extends BaseController< messenger, state, }); + this.#getUseRequestQueue = getUseRequestQueue; this.#registerMessageHandlers(); + + // this is fetching all the dapp permissions from the PermissionsController and looking for any domains that are not in domains state in this controller. Then we take any missing domains and add them to state here, setting it with the globally selected networkClientId (fetched from the NetworkController) + this.messagingSystem + .call('PermissionController:getSubjectNames') + .filter((domain) => this.state.domains[domain] === undefined) + .forEach((domain) => + this.setNetworkClientIdForDomain( + domain, + this.messagingSystem.call('NetworkController:getState') + .selectedNetworkClientId, + ), + ); + + this.messagingSystem.subscribe( + 'PermissionController:stateChange', + (_, patches) => { + patches.forEach(({ op, path }) => { + const isChangingSubject = + path[0] === 'subjects' && path[1] !== undefined; + if (isChangingSubject && typeof path[1] === 'string') { + const domain = path[1]; + if (op === 'add' && this.state.domains[domain] === undefined) { + this.setNetworkClientIdForDomain( + domain, + this.messagingSystem.call('NetworkController:getState') + .selectedNetworkClientId, + ); + } else if ( + op === 'remove' && + this.state.domains[domain] !== undefined + ) { + this.update(({ domains }) => { + delete domains[domain]; + }); + } + } + }); + }, + ); + + this.messagingSystem.subscribe( + 'NetworkController:stateChange', + ({ selectedNetworkClientId }, patches) => { + patches.forEach(({ op, path }) => { + // if a network is removed, update the networkClientId for all domains that were using it to the selected network + if (op === 'remove' && path[0] === 'networkConfigurations') { + const removedNetworkClientId = path[1] as NetworkClientId; + Object.entries(this.state.domains).forEach( + ([domain, networkClientIdForDomain]) => { + if (networkClientIdForDomain === removedNetworkClientId) { + this.setNetworkClientIdForDomain( + domain, + selectedNetworkClientId, + ); + } + }, + ); + } + }); + }, + ); } #registerMessageHandlers(): void { @@ -133,17 +204,12 @@ export class SelectedNetworkController extends BaseController< SelectedNetworkControllerActionTypes.getNetworkClientIdForDomain, this.getNetworkClientIdForDomain.bind(this), ); - this.messagingSystem.registerActionHandler( SelectedNetworkControllerActionTypes.setNetworkClientIdForDomain, this.setNetworkClientIdForDomain.bind(this), ); } - setNetworkClientIdForMetamask(networkClientId: NetworkClientId) { - this.setNetworkClientIdForDomain(METAMASK_DOMAIN, networkClientId); - } - #setNetworkClientIdForDomain( domain: Domain, networkClientId: NetworkClientId, @@ -167,36 +233,42 @@ export class SelectedNetworkController extends BaseController< this.update((state) => { state.domains[domain] = networkClientId; - if (!state.perDomainNetwork) { - state.domains[METAMASK_DOMAIN] = networkClientId; - } }); } + #domainHasPermissions(domain: Domain): boolean { + return this.messagingSystem.call( + 'PermissionController:hasPermissions', + domain, + ); + } + setNetworkClientIdForDomain( domain: Domain, networkClientId: NetworkClientId, ) { - if (!this.state.perDomainNetwork) { - Object.entries(this.state.domains).forEach( - ([entryDomain, networkClientIdForDomain]) => { - if ( - networkClientIdForDomain !== networkClientId && - entryDomain !== domain - ) { - this.#setNetworkClientIdForDomain(entryDomain, networkClientId); - } - }, + if (domain === METAMASK_DOMAIN) { + throw new Error( + `NetworkClientId for domain "${METAMASK_DOMAIN}" cannot be set on the SelectedNetworkController`, ); } + + if (!this.#domainHasPermissions(domain)) { + throw new Error( + 'NetworkClientId for domain cannot be called with a domain that has not yet been granted permissions', + ); + } + this.#setNetworkClientIdForDomain(domain, networkClientId); } getNetworkClientIdForDomain(domain: Domain): NetworkClientId { - if (this.state.perDomainNetwork) { - return this.state.domains[domain] ?? this.state.domains[METAMASK_DOMAIN]; + const { selectedNetworkClientId: metamaskSelectedNetworkClientId } = + this.messagingSystem.call('NetworkController:getState'); + if (!this.#getUseRequestQueue()) { + return metamaskSelectedNetworkClientId; } - return this.state.domains[METAMASK_DOMAIN]; + return this.state.domains[domain] ?? metamaskSelectedNetworkClientId; } /** @@ -206,11 +278,22 @@ export class SelectedNetworkController extends BaseController< * @returns The proxy and block tracker proxies. */ getProviderAndBlockTracker(domain: Domain): NetworkProxy { + if (!this.#getUseRequestQueue()) { + throw new Error( + 'Provider and BlockTracker should be fetched from NetworkController when useRequestQueue is false', + ); + } + const networkClientId = this.state.domains[domain]; + if (!networkClientId) { + throw new Error( + 'NetworkClientId has not been set for the requested domain', + ); + } let networkProxy = this.#proxies.get(domain); if (networkProxy === undefined) { const networkClient = this.messagingSystem.call( 'NetworkController:getNetworkClientById', - this.getNetworkClientIdForDomain(domain), + networkClientId, ); networkProxy = { provider: createEventEmitterProxy(networkClient.provider), @@ -223,18 +306,4 @@ export class SelectedNetworkController extends BaseController< return networkProxy; } - - setPerDomainNetwork(enabled: boolean) { - this.update((state) => { - state.perDomainNetwork = enabled; - return state; - }); - Object.keys(this.state.domains).forEach((domain) => { - // when perDomainNetwork is false, getNetworkClientIdForDomain always returns the networkClientId for the domain 'metamask' - this.setNetworkClientIdForDomain( - domain, - this.getNetworkClientIdForDomain(domain), - ); - }); - } } diff --git a/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts b/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts index bcc3aec533..eb84a503e9 100644 --- a/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts +++ b/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts @@ -1,35 +1,17 @@ -import type { ControllerMessenger } from '@metamask/base-controller'; import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; -import type { - NetworkClientId, - NetworkControllerGetStateAction, - NetworkControllerStateChangeEvent, -} from '@metamask/network-controller'; +import type { NetworkClientId } from '@metamask/network-controller'; import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; -import type { - SelectedNetworkControllerGetNetworkClientIdForDomainAction, - SelectedNetworkControllerSetNetworkClientIdForDomainAction, -} from './SelectedNetworkController'; +import type { SelectedNetworkControllerMessenger } from './SelectedNetworkController'; import { SelectedNetworkControllerActionTypes } from './SelectedNetworkController'; -export type MiddlewareAllowedActions = NetworkControllerGetStateAction; -export type MiddlewareAllowedEvents = NetworkControllerStateChangeEvent; - -export type SelectedNetworkMiddlewareMessenger = ControllerMessenger< - | SelectedNetworkControllerGetNetworkClientIdForDomainAction - | SelectedNetworkControllerSetNetworkClientIdForDomainAction - | MiddlewareAllowedActions, - MiddlewareAllowedEvents ->; - export type SelectedNetworkMiddlewareJsonRpcRequest = JsonRpcRequest & { networkClientId?: NetworkClientId; origin?: string; }; export const createSelectedNetworkMiddleware = ( - messenger: SelectedNetworkMiddlewareMessenger, + messenger: SelectedNetworkControllerMessenger, ): JsonRpcMiddleware => { const getNetworkClientIdForDomain = (origin: string) => messenger.call( @@ -37,28 +19,11 @@ export const createSelectedNetworkMiddleware = ( origin, ); - const setNetworkClientIdForDomain = ( - origin: string, - networkClientId: NetworkClientId, - ) => - messenger.call( - SelectedNetworkControllerActionTypes.setNetworkClientIdForDomain, - origin, - networkClientId, - ); - - const getDefaultNetworkClientId = () => - messenger.call('NetworkController:getState').selectedNetworkClientId; - return (req: SelectedNetworkMiddlewareJsonRpcRequest, _, next) => { if (!req.origin) { throw new Error("Request object is lacking an 'origin'"); } - if (getNetworkClientIdForDomain(req.origin) === undefined) { - setNetworkClientIdForDomain(req.origin, getDefaultNetworkClientId()); - } - req.networkClientId = getNetworkClientIdForDomain(req.origin); return next(); }; diff --git a/packages/selected-network-controller/src/index.ts b/packages/selected-network-controller/src/index.ts index 6b2b666d64..f0dfd54e1f 100644 --- a/packages/selected-network-controller/src/index.ts +++ b/packages/selected-network-controller/src/index.ts @@ -14,6 +14,7 @@ export { SelectedNetworkControllerActionTypes, SelectedNetworkControllerEventTypes, SelectedNetworkController, + METAMASK_DOMAIN, } from './SelectedNetworkController'; export type { SelectedNetworkMiddlewareJsonRpcRequest } from './SelectedNetworkMiddleware'; export { createSelectedNetworkMiddleware } from './SelectedNetworkMiddleware'; diff --git a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts index f8a1466ab6..d4dfcd897a 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts @@ -4,28 +4,53 @@ import { createEventEmitterProxy } from '@metamask/swappable-obj-proxy'; import type { AllowedActions, AllowedEvents, + GetUseRequestQueue, SelectedNetworkControllerActions, SelectedNetworkControllerEvents, SelectedNetworkControllerMessenger, - SelectedNetworkControllerOptions, + SelectedNetworkControllerState, } from '../src/SelectedNetworkController'; import { SelectedNetworkController, controllerName, } from '../src/SelectedNetworkController'; +/** + * Builds a new instance of the ControllerMessenger class for the SelectedNetworkController. + * + * @returns A new instance of the ControllerMessenger class for the SelectedNetworkController. + */ +function buildMessenger() { + return new ControllerMessenger< + SelectedNetworkControllerActions | AllowedActions, + SelectedNetworkControllerEvents | AllowedEvents + >(); +} + /** * Build a restricted controller messenger for the selected network controller. * - * @param messenger - A controller messenger. + * @param options - The options bag. + * @param options.messenger - A controller messenger. + * @param options.hasPermissions - Whether the requesting domain has permissions. + * @param options.getSubjectNames - Permissions controller list of domains with permissions * @returns The network controller restricted messenger. */ -export function buildSelectedNetworkControllerMessenger( +export function buildSelectedNetworkControllerMessenger({ messenger = new ControllerMessenger< SelectedNetworkControllerActions | AllowedActions, SelectedNetworkControllerEvents | AllowedEvents >(), -): SelectedNetworkControllerMessenger { + hasPermissions, + getSubjectNames, +}: { + messenger?: ControllerMessenger< + SelectedNetworkControllerActions | AllowedActions, + SelectedNetworkControllerEvents | AllowedEvents + >; + hasPermissions?: boolean; + getSubjectNames?: string[]; +} = {}): SelectedNetworkControllerMessenger { messenger.registerActionHandler( 'NetworkController:getNetworkClientById', jest.fn().mockReturnValue({ @@ -33,239 +58,433 @@ export function buildSelectedNetworkControllerMessenger( blockTracker: { getLatestBlock: jest.fn() }, }), ); + messenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ selectedNetworkClientId: 'mainnet' }), + ); + messenger.registerActionHandler( + 'PermissionController:hasPermissions', + jest.fn().mockReturnValue(hasPermissions), + ); + messenger.registerActionHandler( + 'PermissionController:getSubjectNames', + jest.fn().mockReturnValue(getSubjectNames), + ); return messenger.getRestricted({ name: controllerName, - allowedActions: ['NetworkController:getNetworkClientById'], - allowedEvents: ['NetworkController:stateChange'], + allowedActions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'PermissionController:hasPermissions', + 'PermissionController:getSubjectNames', + ], + allowedEvents: [ + 'NetworkController:stateChange', + 'PermissionController:stateChange', + ], }); } jest.mock('@metamask/swappable-obj-proxy'); -const createEventEmitterProxyMock = jest.mocked(createEventEmitterProxy); + +const setup = ({ + hasPermissions = true, + getSubjectNames = [], + state, + getUseRequestQueue = () => false, +}: { + hasPermissions?: boolean; + state?: SelectedNetworkControllerState; + getSubjectNames?: string[]; + getUseRequestQueue?: GetUseRequestQueue; +} = {}) => { + const mockProviderProxy = { + setTarget: jest.fn(), + eventNames: jest.fn(), + rawListeners: jest.fn(), + removeAllListeners: jest.fn(), + on: jest.fn(), + prependListener: jest.fn(), + addListener: jest.fn(), + off: jest.fn(), + once: jest.fn(), + }; + const mockBlockTrackerProxy = { + setTarget: jest.fn(), + eventNames: jest.fn(), + rawListeners: jest.fn(), + removeAllListeners: jest.fn(), + on: jest.fn(), + prependListener: jest.fn(), + addListener: jest.fn(), + off: jest.fn(), + once: jest.fn(), + }; + + const createEventEmitterProxyMock = jest.mocked(createEventEmitterProxy); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createEventEmitterProxyMock.mockImplementation((initialTarget: any) => { + if (initialTarget?.sendAsync !== undefined) { + return mockProviderProxy; + } + if (initialTarget?.getLatestBlock !== undefined) { + return mockBlockTrackerProxy; + } + return mockProviderProxy; + }); + const messenger = buildMessenger(); + const selectedNetworkControllerMessenger = + buildSelectedNetworkControllerMessenger({ + messenger, + hasPermissions, + getSubjectNames, + }); + const controller = new SelectedNetworkController({ + messenger: selectedNetworkControllerMessenger, + state, + getUseRequestQueue, + }); + return { + controller, + messenger, + mockProviderProxy, + mockBlockTrackerProxy, + createEventEmitterProxyMock, + }; +}; describe('SelectedNetworkController', () => { - beforeEach(() => { - createEventEmitterProxyMock.mockReset(); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('constructor', () => { + it('can be instantiated with default values', () => { + const { controller } = setup(); + expect(controller.state).toStrictEqual({ + domains: {}, + }); + }); + it('can be instantiated with a state', () => { + const { controller } = setup({ + state: { + domains: { networkClientId: 'goerli' }, + }, + }); + expect(controller.state).toStrictEqual({ + domains: { networkClientId: 'goerli' }, + }); + }); }); - it('can be instantiated with default values', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; + describe('It updates domain state when the network controller state changes', () => { + describe('when a networkClient is deleted from the network controller state', () => { + it('updates the networkClientId for domains which were previously set to the deleted networkClientId', () => { + const { controller, messenger } = setup({ + state: { + domains: { + metamask: 'goerli', + 'example.com': 'test-network-client-id', + 'test.com': 'test-network-client-id', + }, + }, + }); - const controller = new SelectedNetworkController(options); - expect(controller.state).toStrictEqual({ - domains: {}, - perDomainNetwork: false, + messenger.publish( + 'NetworkController:stateChange', + { + providerConfig: { chainId: '0x5', ticker: 'ETH', type: 'goerli' }, + selectedNetworkClientId: 'goerli', + networkConfigurations: {}, + networksMetadata: {}, + }, + [ + { + op: 'remove', + path: ['networkConfigurations', 'test-network-client-id'], + }, + ], + ); + expect(controller.state.domains['example.com']).toBe('goerli'); + expect(controller.state.domains['test.com']).toBe('goerli'); + }); }); }); describe('setNetworkClientIdForDomain', () => { - it('sets the networkClientId for the metamask domain, when the perDomainNetwork option is false (default)', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; - const controller = new SelectedNetworkController(options); - const networkClientId = 'network2'; - controller.setNetworkClientIdForDomain('not-metamask', networkClientId); - expect(controller.state.domains.metamask).toBe(networkClientId); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should throw an error when passed "metamask" as domain arg', () => { + const { controller } = setup(); + expect(() => { + controller.setNetworkClientIdForDomain('metamask', 'mainnet'); + }).toThrow( + 'NetworkClientId for domain "metamask" cannot be set on the SelectedNetworkController', + ); + expect(controller.state.domains.metamask).toBeUndefined(); }); + describe('when the useRequestQueue is false', () => { + describe('when the requesting domain is not metamask', () => { + it('updates the networkClientId for domain in state', () => { + const { controller } = setup({ + state: { + domains: { + '1.com': 'mainnet', + '2.com': 'mainnet', + '3.com': 'mainnet', + }, + }, + }); + const domains = ['1.com', '2.com', '3.com']; + const networkClientIds = ['1', '2', '3']; - it('sets the networkClientId for the passed in domain, when the perDomainNetwork option is true ,', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; - const controller = new SelectedNetworkController(options); - controller.state.perDomainNetwork = true; - const domain = 'example.com'; - const networkClientId = 'network1'; - controller.setNetworkClientIdForDomain(domain, networkClientId); - expect(controller.state.domains[domain]).toBe(networkClientId); + domains.forEach((domain, i) => + controller.setNetworkClientIdForDomain(domain, networkClientIds[i]), + ); + + expect(controller.state.domains['1.com']).toBe('1'); + expect(controller.state.domains['2.com']).toBe('2'); + expect(controller.state.domains['3.com']).toBe('3'); + }); + }); }); - it('when the perDomainNetwork option is false, it updates the networkClientId for all domains in state', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; - const controller = new SelectedNetworkController(options); - controller.state.perDomainNetwork = false; - const domains = ['1.com', '2.com', '3.com']; - const networkClientIds = ['1', '2', '3']; - const mockProviderProxy = { - setTarget: jest.fn(), - eventNames: jest.fn(), - rawListeners: jest.fn(), - removeAllListeners: jest.fn(), - on: jest.fn(), - prependListener: jest.fn(), - addListener: jest.fn(), - off: jest.fn(), - once: jest.fn(), - }; - createEventEmitterProxyMock.mockReturnValue(mockProviderProxy); - controller.setNetworkClientIdForMetamask('abc'); - domains.forEach((domain, i) => - controller.setNetworkClientIdForDomain(domain, networkClientIds[i]), - ); + describe('when the useRequestQueue is true', () => { + describe('when the requesting domain has existing permissions', () => { + it('sets the networkClientId for the passed in domain', () => { + const { controller } = setup({ + state: { domains: {} }, + hasPermissions: true, + getUseRequestQueue: () => true, + }); - controller.setNetworkClientIdForMetamask('foo'); - domains.forEach((domain) => - expect(controller.state.domains[domain]).toBe('foo'), - ); + const domain = 'example.com'; + const networkClientId = 'network1'; + controller.setNetworkClientIdForDomain(domain, networkClientId); + expect(controller.state.domains[domain]).toBe(networkClientId); + }); - controller.setNetworkClientIdForMetamask('abc'); - domains.forEach((domain) => - expect(controller.state.domains[domain]).toBe('abc'), - ); - }); + it('updates the provider and block tracker proxy when they already exist for the domain', () => { + const { controller, mockProviderProxy } = setup({ + state: { domains: {} }, + hasPermissions: true, + getUseRequestQueue: () => true, + }); + const initialNetworkClientId = '123'; - it('creates a new provider and block tracker proxy when they dont exist yet for the domain', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; - const controller = new SelectedNetworkController(options); - - const initialNetworkClientId = '123'; - const mockProviderProxy = { - setTarget: jest.fn(), - eventNames: jest.fn(), - rawListeners: jest.fn(), - removeAllListeners: jest.fn(), - on: jest.fn(), - prependListener: jest.fn(), - addListener: jest.fn(), - off: jest.fn(), - once: jest.fn(), - }; - createEventEmitterProxyMock.mockReturnValue(mockProviderProxy); - controller.setNetworkClientIdForDomain( - 'example.com', - initialNetworkClientId, - ); - expect(createEventEmitterProxyMock).toHaveBeenCalledTimes(2); - }); + // creates the proxy for the new domain + controller.setNetworkClientIdForDomain( + 'example.com', + initialNetworkClientId, + ); + const newNetworkClientId = 'abc'; - it('updates the provider and block tracker proxy when they already exist for the domain', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; - const controller = new SelectedNetworkController(options); - - const initialNetworkClientId = '123'; - const mockProviderProxy = { - setTarget: jest.fn(), - eventNames: jest.fn(), - rawListeners: jest.fn(), - removeAllListeners: jest.fn(), - on: jest.fn(), - prependListener: jest.fn(), - addListener: jest.fn(), - off: jest.fn(), - once: jest.fn(), - }; - createEventEmitterProxyMock.mockReturnValue(mockProviderProxy); - controller.setNetworkClientIdForDomain( - 'example.com', - initialNetworkClientId, - ); - const newNetworkClientId = 'abc'; - controller.setNetworkClientIdForDomain('example.com', newNetworkClientId); + // calls setTarget on the proxy + controller.setNetworkClientIdForDomain( + 'example.com', + newNetworkClientId, + ); - expect(mockProviderProxy.setTarget).toHaveBeenCalledWith( - expect.objectContaining({ sendAsync: expect.any(Function) }), - ); - expect(mockProviderProxy.setTarget).toHaveBeenCalledTimes(2); + expect(mockProviderProxy.setTarget).toHaveBeenCalledWith( + expect.objectContaining({ sendAsync: expect.any(Function) }), + ); + expect(mockProviderProxy.setTarget).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the requesting domain does not have permissions', () => { + it('throw an error and does not set the networkClientId for the passed in domain', () => { + const { controller } = setup({ + state: { domains: {} }, + hasPermissions: false, + }); + + const domain = 'example.com'; + const networkClientId = 'network1'; + expect(() => { + controller.setNetworkClientIdForDomain(domain, networkClientId); + }).toThrow( + 'NetworkClientId for domain cannot be called with a domain that has not yet been granted permissions', + ); + expect(controller.state.domains[domain]).toBeUndefined(); + }); + }); }); }); describe('getNetworkClientIdForDomain', () => { - it('returns the networkClientId for the metamask domain, when the perDomainNetwork option is false (default)', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; - const controller = new SelectedNetworkController(options); - const networkClientId = 'network4'; - controller.setNetworkClientIdForMetamask(networkClientId); - const result = controller.getNetworkClientIdForDomain('example.com'); - expect(result).toBe(networkClientId); + describe('when the useRequestQueue is false', () => { + it('returns the selectedNetworkClientId from the NetworkController if not no networkClientId is set for requested domain', () => { + const { controller } = setup(); + expect(controller.getNetworkClientIdForDomain('example.com')).toBe( + 'mainnet', + ); + }); + it('returns the selectedNetworkClientId from the NetworkController if a networkClientId is set for the requested domain', () => { + const { controller } = setup(); + const networkClientId = 'network3'; + controller.setNetworkClientIdForDomain('example.com', networkClientId); + expect(controller.getNetworkClientIdForDomain('example.com')).toBe( + 'mainnet', + ); + }); + it('returns the networkClientId for the metamask domain when passed "metamask"', () => { + const { controller } = setup(); + const result = controller.getNetworkClientIdForDomain('metamask'); + expect(result).toBe('mainnet'); + }); }); - it('returns the networkClientId for the passed in domain, when the perDomainNetwork option is true', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; - const controller = new SelectedNetworkController(options); - controller.state.perDomainNetwork = true; - const networkClientId1 = 'network5'; - const networkClientId2 = 'network6'; - controller.setNetworkClientIdForDomain('example.com', networkClientId1); - controller.setNetworkClientIdForDomain('test.com', networkClientId2); - const result1 = controller.getNetworkClientIdForDomain('example.com'); - const result2 = controller.getNetworkClientIdForDomain('test.com'); - expect(result1).toBe(networkClientId1); - expect(result2).toBe(networkClientId2); - }); + describe('when the useRequestQueue is true', () => { + it('returns the networkClientId for the passed in domain, when a networkClientId has been set for the requested domain', () => { + const { controller } = setup({ + state: { domains: {} }, + hasPermissions: true, + getUseRequestQueue: () => true, + }); + const networkClientId1 = 'network5'; + const networkClientId2 = 'network6'; + controller.setNetworkClientIdForDomain('example.com', networkClientId1); + controller.setNetworkClientIdForDomain('test.com', networkClientId2); + const result1 = controller.getNetworkClientIdForDomain('example.com'); + const result2 = controller.getNetworkClientIdForDomain('test.com'); + expect(result1).toBe(networkClientId1); + expect(result2).toBe(networkClientId2); + }); - it('returns the networkClientId for the metamask domain, when the perDomainNetwork option is true, but no networkClientId has been set for the domain requested', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; - const controller = new SelectedNetworkController(options); - controller.state.perDomainNetwork = true; - const networkClientId = 'network7'; - controller.setNetworkClientIdForMetamask(networkClientId); - const result = controller.getNetworkClientIdForDomain('example.com'); - expect(result).toBe(networkClientId); + it('returns the selectedNetworkClientId from the NetworkController when no networkClientId has been set for the domain requested', () => { + const { controller } = setup({ + state: { domains: {} }, + hasPermissions: true, + getUseRequestQueue: () => true, + }); + expect(controller.getNetworkClientIdForDomain('example.com')).toBe( + 'mainnet', + ); + }); }); }); describe('getProviderAndBlockTracker', () => { - it('returns a proxy provider and block tracker when there is one already', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; - const controller = new SelectedNetworkController(options); - controller.setNetworkClientIdForDomain('example.com', 'network7'); - const result = controller.getProviderAndBlockTracker('example.com'); - expect(result).toBeDefined(); + describe('when useRequestQueue is true', () => { + it('returns a proxy provider and block tracker when a networkClientId has been set for the requested domain', () => { + const { controller } = setup({ + state: { + domains: {}, + }, + getUseRequestQueue: () => true, + }); + controller.setNetworkClientIdForDomain('example.com', 'network7'); + const result = controller.getProviderAndBlockTracker('example.com'); + expect(result).toBeDefined(); + }); + + it('creates a new proxy provider and block tracker when there isnt one already', () => { + const { controller } = setup({ + state: { + domains: { + 'test.com': 'mainnet', + }, + }, + getUseRequestQueue: () => true, + }); + const result = controller.getProviderAndBlockTracker('test.com'); + expect(result).toBeDefined(); + }); + + it('throws and error when a networkClientId has not been set for the requested domain', () => { + const { controller } = setup({ + state: { + domains: {}, + }, + getUseRequestQueue: () => true, + }); + + expect(() => { + controller.getProviderAndBlockTracker('test.com'); + }).toThrow('NetworkClientId has not been set for the requested domain'); + }); }); + describe('when useRequestQueue is false', () => { + it('throws and error when a networkClientId has been been set for the requested domain', () => { + const { controller } = setup({ + state: { + domains: {}, + }, + }); - it('creates a new proxy provider and block tracker when there isnt one already', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), + expect(() => { + controller.getProviderAndBlockTracker('test.com'); + }).toThrow( + 'Provider and BlockTracker should be fetched from NetworkController when useRequestQueue is false', + ); + }); + }); + }); + describe('When a permission is added or removed', () => { + it('should add new domain to domains list on permission add', async () => { + const { controller, messenger } = setup(); + const mockPermission = { + parentCapability: 'eth_accounts', + id: 'example.com', + date: Date.now(), + caveats: [{ type: 'restrictToAccounts', value: ['0x...'] }], }; - const controller = new SelectedNetworkController(options); - expect( - controller.getNetworkClientIdForDomain('test.com'), - ).toBeUndefined(); - const result = controller.getProviderAndBlockTracker('test.com'); - expect(result).toBeDefined(); + + messenger.publish('PermissionController:stateChange', { subjects: {} }, [ + { + op: 'add', + path: ['subjects', 'example.com', 'permissions'], + value: mockPermission, + }, + ]); + + const { domains } = controller.state; + expect(domains['example.com']).toBeDefined(); + }); + + it('should remove domain from domains list on permission removal', async () => { + const { controller, messenger } = setup({ + state: { domains: { 'example.com': 'foo' } }, + }); + + messenger.publish('PermissionController:stateChange', { subjects: {} }, [ + { + op: 'remove', + path: ['subjects', 'example.com', 'permissions'], + }, + ]); + + const { domains } = controller.state; + expect(domains['example.com']).toBeUndefined(); }); }); + describe('Constructor checks for domains in permissions', () => { + it('should set networkClientId for domains not already in state', async () => { + const getSubjectNamesMock = ['newdomain.com']; + const { controller } = setup({ + state: { domains: {} }, + getSubjectNames: getSubjectNamesMock, + }); - describe('setPerDomainNetwork', () => { - it('toggles the feature flag & updates the proxies for each domain', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - state: { domains: {}, perDomainNetwork: false }, - }; - const controller = new SelectedNetworkController(options); - const mockProviderProxy = { - setTarget: jest.fn(), - eventNames: jest.fn(), - rawListeners: jest.fn(), - removeAllListeners: jest.fn(), - on: jest.fn(), - prependListener: jest.fn(), - addListener: jest.fn(), - off: jest.fn(), - once: jest.fn(), - }; - createEventEmitterProxyMock.mockReturnValue(mockProviderProxy); - controller.setNetworkClientIdForDomain('example.com', 'network7'); - expect(mockProviderProxy.setTarget).toHaveBeenCalledTimes(0); - controller.setPerDomainNetwork(true); - expect(mockProviderProxy.setTarget).toHaveBeenCalledTimes(2); + // Now, 'newdomain.com' should have the selectedNetworkClientId set + expect(controller.state.domains['newdomain.com']).toBe('mainnet'); + }); + + it('should not modify domains already in state', async () => { + const { controller } = setup({ + state: { + domains: { + 'existingdomain.com': 'initialNetworkId', + }, + }, + getSubjectNames: ['existingdomain.com'], + }); + + // The 'existingdomain.com' should retain its initial networkClientId + expect(controller.state.domains['existingdomain.com']).toBe( + 'initialNetworkId', + ); }); }); }); diff --git a/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts b/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts index d03c9caf60..ce07dc20f0 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts @@ -4,13 +4,19 @@ import type { JsonRpcResponse } from '@metamask/utils'; import { SelectedNetworkControllerActionTypes } from '../src/SelectedNetworkController'; import type { - SelectedNetworkMiddlewareJsonRpcRequest, - SelectedNetworkMiddlewareMessenger, -} from '../src/SelectedNetworkMiddleware'; + AllowedActions, + AllowedEvents, + SelectedNetworkControllerActions, + SelectedNetworkControllerEvents, +} from '../src/SelectedNetworkController'; +import type { SelectedNetworkMiddlewareJsonRpcRequest } from '../src/SelectedNetworkMiddleware'; import { createSelectedNetworkMiddleware } from '../src/SelectedNetworkMiddleware'; -const buildMessenger = (): SelectedNetworkMiddlewareMessenger => { - return new ControllerMessenger(); +const buildMessenger = () => { + return new ControllerMessenger< + SelectedNetworkControllerActions | AllowedActions, + SelectedNetworkControllerEvents | AllowedEvents + >(); }; const noop = jest.fn(); @@ -18,7 +24,11 @@ const noop = jest.fn(); describe('createSelectedNetworkMiddleware', () => { it('throws if not provided an origin', async () => { const messenger = buildMessenger(); - const middleware = createSelectedNetworkMiddleware(messenger); + const middleware = createSelectedNetworkMiddleware( + messenger.getRestricted({ + name: 'SelectedNetworkController', + }), + ); const req: SelectedNetworkMiddlewareJsonRpcRequest = { id: '123', jsonrpc: '2.0', @@ -36,7 +46,11 @@ describe('createSelectedNetworkMiddleware', () => { it('puts networkClientId on request', async () => { const messenger = buildMessenger(); - const middleware = createSelectedNetworkMiddleware(messenger); + const middleware = createSelectedNetworkMiddleware( + messenger.getRestricted({ + name: 'SelectedNetworkController', + }), + ); const req = { origin: 'example.com', @@ -58,48 +72,6 @@ describe('createSelectedNetworkMiddleware', () => { expect(req.networkClientId).toBe('mockNetworkClientId'); }); - it('sets the networkClientId for the domain to the current network from networkController if one is not set', async () => { - const messenger = buildMessenger(); - const middleware = createSelectedNetworkMiddleware(messenger); - - const req = { - origin: 'example.com', - } as SelectedNetworkMiddlewareJsonRpcRequest; - - const mockGetNetworkClientIdForDomain = jest - .fn() - .mockReturnValueOnce(undefined) - .mockReturnValueOnce('defaultNetworkClientId'); - const mockSetNetworkClientIdForDomain = jest.fn(); - const mockNetworkControllerGetState = jest.fn().mockReturnValue({ - selectedNetworkClientId: 'defaultNetworkClientId', - }); - messenger.registerActionHandler( - SelectedNetworkControllerActionTypes.getNetworkClientIdForDomain, - mockGetNetworkClientIdForDomain, - ); - messenger.registerActionHandler( - SelectedNetworkControllerActionTypes.setNetworkClientIdForDomain, - mockSetNetworkClientIdForDomain, - ); - messenger.registerActionHandler( - 'NetworkController:getState', - mockNetworkControllerGetState, - ); - - await new Promise((resolve) => - middleware(req, {} as JsonRpcResponse, resolve, noop), - ); - - expect(mockGetNetworkClientIdForDomain).toHaveBeenCalledWith('example.com'); - expect(mockNetworkControllerGetState).toHaveBeenCalled(); - expect(mockSetNetworkClientIdForDomain).toHaveBeenCalledWith( - 'example.com', - 'defaultNetworkClientId', - ); - expect(req.networkClientId).toBe('defaultNetworkClientId'); - }); - it('implements the json-rpc-engine middleware interface appropriately', async () => { const engine = new JsonRpcEngine(); const messenger = buildMessenger(); @@ -107,7 +79,13 @@ describe('createSelectedNetworkMiddleware', () => { req.origin = 'foobar'; next(); }); - engine.push(createSelectedNetworkMiddleware(messenger)); + engine.push( + createSelectedNetworkMiddleware( + messenger.getRestricted({ + name: 'SelectedNetworkController', + }), + ), + ); const mockNextMiddleware = jest .fn() .mockImplementation((req, res, _, end) => { diff --git a/packages/selected-network-controller/tsconfig.build.json b/packages/selected-network-controller/tsconfig.build.json index 51944fc30a..0113f47641 100644 --- a/packages/selected-network-controller/tsconfig.build.json +++ b/packages/selected-network-controller/tsconfig.build.json @@ -8,7 +8,8 @@ "references": [ { "path": "../base-controller/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, - { "path": "../json-rpc-engine/tsconfig.build.json" } + { "path": "../json-rpc-engine/tsconfig.build.json" }, + { "path": "../permission-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/selected-network-controller/tsconfig.json b/packages/selected-network-controller/tsconfig.json index 5293b22cfb..9e391177a6 100644 --- a/packages/selected-network-controller/tsconfig.json +++ b/packages/selected-network-controller/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../json-rpc-engine" + }, + { + "path": "../permission-controller" } ], "include": ["../../types", "../../tests", "./src", "./tests"] diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index 28c9b8a70a..0c277b9a22 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -33,13 +33,12 @@ "dependencies": { "@metamask/approval-controller": "^5.1.2", "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/keyring-controller": "^12.2.0", "@metamask/logging-controller": "^2.0.2", "@metamask/message-manager": "^7.3.8", - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/utils": "^8.3.0", - "ethereumjs-util": "^7.0.10", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/packages/signature-controller/src/SignatureController.ts b/packages/signature-controller/src/SignatureController.ts index 7170645ae7..ee73ec4024 100644 --- a/packages/signature-controller/src/SignatureController.ts +++ b/packages/signature-controller/src/SignatureController.ts @@ -46,7 +46,7 @@ import { } from '@metamask/message-manager'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { Hex, Json } from '@metamask/utils'; -import { bufferToHex } from 'ethereumjs-util'; +import { bytesToHex } from '@metamask/utils'; import EventEmitter from 'events'; import { cloneDeep } from 'lodash'; @@ -861,7 +861,7 @@ export class SignatureController extends BaseController< return data; } // data is unicode, convert to hex - return bufferToHex(Buffer.from(data, 'utf8')); + return bytesToHex(Buffer.from(data, 'utf8')); } #getMessage(messageId: string): StateMessage { diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index ccd19e3e5b..338b07d991 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -11,6 +11,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +## [23.1.0] + +### Added + +- Add `gasFeeEstimatesLoaded` property to `TransactionMeta` ([#3948](https://github.com/MetaMask/core/pull/3948)) +- Add `gasFeeEstimates` property to `TransactionMeta` to be automatically populated on unapproved transactions ([#3913](https://github.com/MetaMask/core/pull/3913)) + +### Changed + +- Use the `linea_estimateGas` RPC method to provide transaction specific gas fee estimates on Linea networks ([#3913](https://github.com/MetaMask/core/pull/3913)) + +## [23.0.0] + +### Added + +- **BREAKING:** Constructor now expects a `getNetworkClientRegistry` callback function ([#3643](https://github.com/MetaMask/core/pull/3643)) +- **BREAKING:** Messenger now requires `NetworkController:stateChange` to be an allowed event ([#3643](https://github.com/MetaMask/core/pull/3643)) +- **BREAKING:** Messenger now requires `NetworkController:findNetworkClientByChainId` and `NetworkController:getNetworkClientById` actions ([#3643](https://github.com/MetaMask/core/pull/3643)) +- Adds a feature flag parameter `isMultichainEnabled` passed via the constructor (and defaulted to false), which when passed a truthy value will enable the controller to submit, process, and track transactions concurrently on multiple networks. ([#3643](https://github.com/MetaMask/core/pull/3643)) +- Adds `destroy()` method that stops/removes internal polling and listeners ([#3643](https://github.com/MetaMask/core/pull/3643)) +- Adds `stopAllIncomingTransactionPolling()` method that stops polling Etherscan for transaction updates relevant to the currently selected network. + - When called with the `isMultichainEnabled` feature flag on, also stops polling Etherscan for transaction updates relevant to each currently polled networkClientId. ([#3643](https://github.com/MetaMask/core/pull/3643)) +- Exports `PendingTransactionOptions` type ([#3643](https://github.com/MetaMask/core/pull/3643)) +- Exports `TransactionControllerOptions` type ([#3643](https://github.com/MetaMask/core/pull/3643)) + +### Changed + +- **BREAKING:** `approveTransactionsWithSameNonce()` now requires `chainId` to be populated in for each TransactionParams that is passed ([#3643](https://github.com/MetaMask/core/pull/3643)) +- `addTransaction()` now accepts optional `networkClientId` in its options param which specifies the network client that the transaction will be processed with during its lifecycle if the `isMultichainEnabled` feature flag is on ([#3643](https://github.com/MetaMask/core/pull/3643)) + - when called with the `isMultichainEnabled` feature flag off, passing in a networkClientId will cause an error to be thrown. +- `estimateGas()` now accepts optional networkClientId as its last param which specifies the network client that should be used to estimate the required gas for the given transaction ([#3643](https://github.com/MetaMask/core/pull/3643)) + - when called with the `isMultichainEnabled` feature flag is off, the networkClientId param is ignored and the global network client will be used instead. +- `estimateGasBuffered()` now accepts optional networkClientId as its last param which specifies the network client that should be used to estimate the required gas plus buffer for the given transaction ([#3643](https://github.com/MetaMask/core/pull/3643)) + - when called with the `isMultichainEnabled` feature flag is off, the networkClientId param is ignored and the global network client will be used instead. +- `getNonceLock()` now accepts optional networkClientId as its last param which specifies which the network client's nonceTracker should be used to determine the next nonce. ([#3643](https://github.com/MetaMask/core/pull/3643)) + - When called with the `isMultichainEnabled` feature flag on and with networkClientId specified, this method will also restrict acquiring the next nonce by chainId, i.e. if this method is called with two different networkClientIds on the same chainId, only the first call will return immediately with a lock from its respective nonceTracker with the second call being blocked until the first caller releases its lock + - When called with `isMultichainEnabled` feature flag off, the networkClientId param is ignored and the global network client will be used instead. +- `startIncomingTransactionPolling()` and `updateIncomingTransactions()` now enforce a 5 second delay between requests per chainId to avoid rate limiting ([#3643](https://github.com/MetaMask/core/pull/3643)) +- `TransactionMeta` type now specifies an optional `networkClientId` field ([#3643](https://github.com/MetaMask/core/pull/3643)) +- `startIncomingTransactionPolling()` now accepts an optional array of `networkClientIds`. ([#3643](https://github.com/MetaMask/core/pull/3643)) + - When `networkClientIds` is provided and the `isMultichainEnabled` feature flag is on, the controller will start polling Etherscan for transaction updates relevant to the networkClientIds. + - When `networkClientIds` is provided and the `isMultichainEnabled` feature flag is off, nothing will happen. + - If `networkClientIds` is empty or not provided, the controller will start polling Etherscan for transaction updates relevant to the currently selected network. +- `stopIncomingTransactionPolling()` now accepts an optional array of `networkClientIds`. ([#3643](https://github.com/MetaMask/core/pull/3643)) + - When `networkClientIds` is provided and the `isMultichainEnabled` feature flag is on, the controller will stop polling Ethercsan for transaction updates relevant to the networkClientIds. + - When `networkClientIds` is provided and the `isMultichainEnabled` feature flag is off, nothing will happen. + - If `networkClientIds` is empty or not provided, the controller will stop polling Etherscan for transaction updates relevant to the currently selected network. + +## [22.0.0] + +### Changed + +- **BREAKING:** Add peerDependency on `@babel/runtime` ([#3897](https://github.com/MetaMask/core/pull/3897)) +- Throw after publishing a canceled or sped-up transaction if already confirmed ([#3800](https://github.com/MetaMask/core/pull/3800)) +- Bump `eth-method-registry` from `^3.0.0` to `^4.0.0` ([#3897](https://github.com/MetaMask/core/pull/3897)) +- Bump `@metamask/controller-utils` to `^8.0.3` ([#3915](https://github.com/MetaMask/core/pull/3915)) +- Bump `@metamask/gas-fee-controller` to `^13.0.1` ([#3915](https://github.com/MetaMask/core/pull/3915)) + +### Removed + +- **BREAKING:** Remove `cancelMultiplier` and `speedUpMultiplier` constructor options as both values are now fixed at `1.1`. ([#3909](https://github.com/MetaMask/core/pull/3909)) + +### Fixed + +- Remove implicit peerDependency on `babel-runtime` ([#3897](https://github.com/MetaMask/core/pull/3897)) + ## [21.2.0] ### Added @@ -481,7 +547,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@23.1.0...HEAD +[23.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@23.0.0...@metamask/transaction-controller@23.1.0 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@22.0.0...@metamask/transaction-controller@23.0.0 +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.2.0...@metamask/transaction-controller@22.0.0 [21.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.1.0...@metamask/transaction-controller@21.2.0 [21.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.0.1...@metamask/transaction-controller@21.1.0 [21.0.1]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.0.0...@metamask/transaction-controller@21.0.1 diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js index 3679c698b0..10fa492868 100644 --- a/packages/transaction-controller/jest.config.js +++ b/packages/transaction-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: 89.05, - functions: 93.89, - lines: 97.85, - statements: 97.81, + branches: 91.79, + functions: 98.56, + lines: 98.9, + statements: 98.91, }, }, diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 182e83bc13..1b0c4d1319 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "21.2.0", + "version": "23.1.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", @@ -33,19 +33,20 @@ "dependencies": { "@ethereumjs/common": "^3.2.0", "@ethereumjs/tx": "^4.2.0", + "@ethereumjs/util": "^8.1.0", "@ethersproject/abi": "^5.7.0", "@metamask/approval-controller": "^5.1.2", "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/eth-query": "^4.0.0", - "@metamask/gas-fee-controller": "^13.0.0", + "@metamask/gas-fee-controller": "^13.0.1", "@metamask/metamask-eth-abis": "^3.0.0", "@metamask/network-controller": "^17.2.0", - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/utils": "^8.3.0", "async-mutex": "^0.2.6", + "bn.js": "^5.2.1", "eth-method-registry": "^4.0.0", - "ethereumjs-util": "^7.0.10", "fast-json-patch": "^3.1.1", "lodash": "^4.17.21", "nonce-tracker": "^3.0.0", @@ -55,10 +56,12 @@ "@babel/runtime": "^7.23.9", "@metamask/auto-changelog": "^3.4.4", "@metamask/ethjs-provider-http": "^0.3.0", + "@types/bn.js": "^5.1.5", "@types/jest": "^27.4.1", "@types/node": "^16.18.54", "deepmerge": "^4.2.2", "jest": "^27.5.1", + "nock": "^13.3.1", "sinon": "^9.2.4", "ts-jest": "^27.1.4", "typedoc": "^0.24.8", diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 346f316772..eb2f5af802 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -12,9 +12,12 @@ import { BUILT_IN_NETWORKS, ORIGIN_METAMASK, } from '@metamask/controller-utils'; +import EthQuery from '@metamask/eth-query'; import HttpProvider from '@metamask/ethjs-provider-http'; import type { BlockTracker, + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, NetworkState, Provider, } from '@metamask/network-controller'; @@ -26,7 +29,11 @@ import * as NonceTrackerPackage from 'nonce-tracker'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import { flushPromises } from '../../../tests/helpers'; import { mockNetwork } from '../../../tests/mock-network'; +import { DefaultGasFeeFlow } from './gas-flows/DefaultGasFeeFlow'; +import { LineaGasFeeFlow } from './gas-flows/LineaGasFeeFlow'; +import { GasFeePoller } from './helpers/GasFeePoller'; import { IncomingTransactionHelper } from './helpers/IncomingTransactionHelper'; +import { MultichainTrackingHelper } from './helpers/MultichainTrackingHelper'; import { PendingTransactionTracker } from './helpers/PendingTransactionTracker'; import type { TransactionControllerMessenger, @@ -52,6 +59,16 @@ import { const MOCK_V1_UUID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'; const v1Stub = jest.fn().mockImplementation(() => MOCK_V1_UUID); +jest.mock('./gas-flows/DefaultGasFeeFlow'); +jest.mock('./gas-flows/LineaGasFeeFlow'); +jest.mock('./helpers/GasFeePoller'); +jest.mock('./helpers/IncomingTransactionHelper'); +jest.mock('./helpers/MultichainTrackingHelper'); +jest.mock('./helpers/PendingTransactionTracker'); +jest.mock('./utils/gas'); +jest.mock('./utils/gas-fees'); +jest.mock('./utils/swaps'); + jest.mock('uuid', () => { return { ...jest.requireActual('uuid'), @@ -59,10 +76,6 @@ jest.mock('uuid', () => { }; }); -jest.mock('./utils/gas'); -jest.mock('./utils/gas-fees'); -jest.mock('./utils/swaps'); - // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockFlags: { [key: string]: any } = { @@ -195,9 +208,6 @@ jest.mock('@metamask/eth-query', () => }), ); -jest.mock('./helpers/IncomingTransactionHelper'); -jest.mock('./helpers/PendingTransactionTracker'); - /** * Builds a mock block tracker with a canned block number that can be used in * tests. @@ -224,23 +234,36 @@ function buildMockResultCallbacks(): AcceptResultCallbacks { }; } +/** + * @type AddRequestOptions + * @property approved - Whether transactions should immediately be approved or rejected. + * @property delay - Whether to delay approval or rejection until the returned functions are called. + * @property resultCallbacks - The result callbacks to return when a request is approved. + */ +type AddRequestOptions = { + approved?: boolean; + delay?: boolean; + resultCallbacks?: AcceptResultCallbacks; +}; + /** * Create a mock controller messenger. * * @param opts - Options to customize the mock messenger. - * @param opts.approved - Whether transactions should immediately be approved or rejected. - * @param opts.delay - Whether to delay approval or rejection until the returned functions are called. - * @param opts.resultCallbacks - The result callbacks to return when a request is approved. + * @param opts.addRequest - Options for ApprovalController.addRequest mock. + * @param opts.getNetworkClientById - The function to use as the NetworkController:getNetworkClientById mock. + * @param opts.findNetworkClientIdByChainId - The function to use as the NetworkController:findNetworkClientIdByChainId mock. * @returns The mock controller messenger. */ +// function buildMockMessenger({ - approved, - delay, - resultCallbacks, + addRequest: { approved, delay, resultCallbacks }, + getNetworkClientById, + findNetworkClientIdByChainId, }: { - approved?: boolean; - delay?: boolean; - resultCallbacks?: AcceptResultCallbacks; + addRequest: AddRequestOptions; + getNetworkClientById: NetworkControllerGetNetworkClientByIdAction['handler']; + findNetworkClientIdByChainId: NetworkControllerFindNetworkClientIdByChainIdAction['handler']; }): { messenger: TransactionControllerMessenger; approve: () => void; @@ -258,20 +281,48 @@ function buildMockMessenger({ }); } + const mockSubscribe = jest.fn(); + mockSubscribe.mockImplementation((_type, handler) => { + setTimeout(() => { + handler({}, [ + { + op: 'add', + path: ['networkConfigurations', 'foo'], + value: 'foo', + }, + ]); + }, 0); + }); + const messenger = { - call: jest.fn().mockImplementation(() => { - if (approved) { - return Promise.resolve({ resultCallbacks }); - } + subscribe: mockSubscribe, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + call: jest.fn().mockImplementation((actionType: string, ...args: any[]) => { + switch (actionType) { + case 'ApprovalController:addRequest': + if (approved) { + return Promise.resolve({ resultCallbacks }); + } - if (delay) { - return promise; - } + if (delay) { + return promise; + } - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject({ - code: errorCodes.provider.userRejectedRequest, - }); + // eslint-disable-next-line prefer-promise-reject-errors + return Promise.reject({ + code: errorCodes.provider.userRejectedRequest, + }); + case 'NetworkController:getNetworkClientById': + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (getNetworkClientById as any)(...args); + case 'NetworkController:findNetworkClientIdByChainId': + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (findNetworkClientIdByChainId as any)(...args); + default: + throw new Error( + `A handler for ${actionType} has not been registered`, + ); + } }), } as unknown as TransactionControllerMessenger; @@ -482,17 +533,22 @@ describe('TransactionController', () => { const updatePostTransactionBalanceMock = jest.mocked( updatePostTransactionBalance, ); + const defaultGasFeeFlowClassMock = jest.mocked(DefaultGasFeeFlow); + const lineaGasFeeFlowClassMock = jest.mocked(LineaGasFeeFlow); + const gasFeePollerClassMock = jest.mocked(GasFeePoller); let resultCallbacksMock: AcceptResultCallbacks; let messengerMock: TransactionControllerMessenger; - let rejectMessengerMock: TransactionControllerMessenger; - let delayMessengerMock: TransactionControllerMessenger; // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any let approveTransaction: (value?: any) => void; let getNonceLockSpy: jest.Mock; let incomingTransactionHelperMock: jest.Mocked; let pendingTransactionTrackerMock: jest.Mocked; + let multichainTrackingHelperMock: jest.Mocked; + let defaultGasFeeFlowMock: jest.Mocked; + let lineaGasFeeFlowMock: jest.Mocked; + let gasFeePollerMock: jest.Mocked; let timeCounter = 0; const incomingTransactionHelperClassMock = @@ -505,6 +561,11 @@ describe('TransactionController', () => { typeof PendingTransactionTracker >; + const multichainTrackingHelperClassMock = + MultichainTrackingHelper as jest.MockedClass< + typeof MultichainTrackingHelper + >; + /** * Create a new instance of the TransactionController. * @@ -535,27 +596,102 @@ describe('TransactionController', () => { state?: Partial; } = {}): TransactionController { const finalNetwork = network ?? MOCK_NETWORK; - let messenger = delayMessengerMock; + resultCallbacksMock = buildMockResultCallbacks(); + let addRequestMockOptions: AddRequestOptions; if (approve) { - messenger = messengerMock; + addRequestMockOptions = { + approved: true, + resultCallbacks: resultCallbacksMock, + }; + } else if (reject) { + addRequestMockOptions = { + approved: false, + resultCallbacks: resultCallbacksMock, + }; + } else { + addRequestMockOptions = { + delay: true, + resultCallbacks: resultCallbacksMock, + }; } - if (reject) { - messenger = rejectMessengerMock; - } + const mockGetNetworkClientById = jest + .fn() + .mockImplementation((networkClientId) => { + switch (networkClientId) { + case 'mainnet': + return { + configuration: { + chainId: toHex(1), + }, + blockTracker: finalNetwork.blockTracker, + provider: finalNetwork.provider, + }; + case 'sepolia': + return { + configuration: { + chainId: ChainId.sepolia, + }, + blockTracker: buildMockBlockTracker('0x1'), + provider: MAINNET_PROVIDER, + }; + case 'goerli': + return { + configuration: { + chainId: ChainId.goerli, + }, + blockTracker: buildMockBlockTracker('0x1'), + provider: MAINNET_PROVIDER, + }; + case 'customNetworkClientId-1': + return { + configuration: { + chainId: '0xa', + }, + blockTracker: buildMockBlockTracker('0x1'), + provider: MAINNET_PROVIDER, + }; + default: + throw new Error(`Invalid network client id ${networkClientId}`); + } + }); + + const mockFindNetworkClientIdByChainId = jest + .fn() + .mockImplementation((chainId) => { + switch (chainId) { + case '0x1': + return 'mainnet'; + case ChainId.sepolia: + return 'sepolia'; + case ChainId.goerli: + return 'goerli'; + case '0xa': + return 'customNetworkClientId-1'; + default: + throw new Error("Couldn't find networkClientId for chainId"); + } + }); + + ({ messenger: messengerMock, approve: approveTransaction } = + buildMockMessenger({ + addRequest: addRequestMockOptions, + getNetworkClientById: mockGetNetworkClientById, + findNetworkClientIdByChainId: mockFindNetworkClientIdByChainId, + })); return new TransactionController( { blockTracker: finalNetwork.blockTracker, getNetworkState: () => finalNetwork.state, - getCurrentAccountEIP1559Compatibility: () => true, getCurrentNetworkEIP1559Compatibility: () => true, getSavedGasFees: () => undefined, getGasFeeEstimates: () => Promise.resolve({}), getPermittedAccounts: () => [ACCOUNT_MOCK], getSelectedAddress: () => ACCOUNT_MOCK, - messenger, + getNetworkClientRegistry: jest.fn(), + messenger: messengerMock, onNetworkStateChange: finalNetwork.subscribe, provider: finalNetwork.provider, ...options, @@ -589,51 +725,74 @@ describe('TransactionController', () => { mockFlags[key] = null; } - resultCallbacksMock = buildMockResultCallbacks(); - - messengerMock = buildMockMessenger({ - approved: true, - resultCallbacks: resultCallbacksMock, - }).messenger; - - rejectMessengerMock = buildMockMessenger({ - approved: false, - resultCallbacks: resultCallbacksMock, - }).messenger; - - ({ messenger: delayMessengerMock, approve: approveTransaction } = - buildMockMessenger({ - delay: true, - resultCallbacks: resultCallbacksMock, - })); - getNonceLockSpy = jest.fn().mockResolvedValue({ nextNonce: NONCE_MOCK, releaseLock: () => Promise.resolve(), }); - NonceTrackerPackage.NonceTracker.prototype.getNonceLock = getNonceLockSpy; + incomingTransactionHelperClassMock.mockImplementation(() => { + incomingTransactionHelperMock = { + start: jest.fn(), + stop: jest.fn(), + update: jest.fn(), + hub: { + on: jest.fn(), + removeAllListeners: jest.fn(), + }, + } as unknown as jest.Mocked; + return incomingTransactionHelperMock; + }); - incomingTransactionHelperMock = { - hub: { - on: jest.fn(), - }, - } as unknown as jest.Mocked; + pendingTransactionTrackerClassMock.mockImplementation(() => { + pendingTransactionTrackerMock = { + start: jest.fn(), + stop: jest.fn(), + startIfPendingTransactions: jest.fn(), + hub: { + on: jest.fn(), + removeAllListeners: jest.fn(), + }, + onStateChange: jest.fn(), + forceCheckTransaction: jest.fn(), + } as unknown as jest.Mocked; + return pendingTransactionTrackerMock; + }); - pendingTransactionTrackerMock = { - start: jest.fn(), - hub: { - on: jest.fn(), - }, - } as unknown as jest.Mocked; + multichainTrackingHelperClassMock.mockImplementation(({ provider }) => { + multichainTrackingHelperMock = { + getEthQuery: jest.fn().mockImplementation(() => { + return new EthQuery(provider); + }), + checkForPendingTransactionAndStartPolling: jest.fn(), + getNonceLock: getNonceLockSpy, + initialize: jest.fn(), + has: jest.fn().mockReturnValue(false), + } as unknown as jest.Mocked; + return multichainTrackingHelperMock; + }); - incomingTransactionHelperClassMock.mockReturnValue( - incomingTransactionHelperMock, - ); + defaultGasFeeFlowClassMock.mockImplementation(() => { + defaultGasFeeFlowMock = { + matchesTransaction: () => false, + } as unknown as jest.Mocked; + return defaultGasFeeFlowMock; + }); - pendingTransactionTrackerClassMock.mockReturnValue( - pendingTransactionTrackerMock, - ); + lineaGasFeeFlowClassMock.mockImplementation(() => { + lineaGasFeeFlowMock = { + matchesTransaction: () => false, + } as unknown as jest.Mocked; + return lineaGasFeeFlowMock; + }); + + gasFeePollerClassMock.mockImplementation(() => { + gasFeePollerMock = { + hub: { + on: jest.fn(), + }, + } as unknown as jest.Mocked; + return gasFeePollerMock; + }); }); afterEach(() => { @@ -658,6 +817,17 @@ describe('TransactionController', () => { }); }); + it('provides gas fee flows to GasFeePoller in correct order', () => { + newController(); + + expect(gasFeePollerClassMock).toHaveBeenCalledTimes(1); + expect(gasFeePollerClassMock).toHaveBeenCalledWith( + expect.objectContaining({ + gasFeeFlows: [lineaGasFeeFlowMock], + }), + ); + }); + describe('nonce tracker', () => { it('uses external pending transactions', async () => { const nonceTrackerMock = jest @@ -700,6 +870,8 @@ describe('TransactionController', () => { expect(getExternalPendingTransactions).toHaveBeenCalledTimes(1); expect(getExternalPendingTransactions).toHaveBeenCalledWith( ACCOUNT_MOCK, + // This is undefined for the base nonceTracker + undefined, ); }); }); @@ -710,10 +882,9 @@ describe('TransactionController', () => { updateGasFeesMock.mockReset(); }); - it('submits an approved transaction', async () => { + it('submits approved transactions for all chains', async () => { const mockTransactionMeta = { from: ACCOUNT_MOCK, - chainId: toHex(5), status: TransactionStatus.approved, txParams: { from: ACCOUNT_MOCK, @@ -723,8 +894,21 @@ describe('TransactionController', () => { const mockedTransactions = [ { id: '123', - ...mockTransactionMeta, history: [{ ...mockTransactionMeta, id: '123' }], + chainId: toHex(5), + ...mockTransactionMeta, + }, + { + id: '456', + history: [{ ...mockTransactionMeta, id: '456' }], + chainId: toHex(1), + ...mockTransactionMeta, + }, + { + id: '789', + history: [{ ...mockTransactionMeta, id: '789' }], + chainId: toHex(16), + ...mockTransactionMeta, }, ]; @@ -745,6 +929,8 @@ describe('TransactionController', () => { const { transactions } = controller.state; expect(transactions[0].status).toBe(TransactionStatus.submitted); + expect(transactions[1].status).toBe(TransactionStatus.submitted); + expect(transactions[2].status).toBe(TransactionStatus.submitted); }); }); }); @@ -861,8 +1047,8 @@ describe('TransactionController', () => { const secondTransactionCount = controller.state.transactions.length; expect(firstTransactionCount).toStrictEqual(secondTransactionCount); - expect(delayMessengerMock.call).toHaveBeenCalledTimes(1); - expect(delayMessengerMock.call).toHaveBeenCalledWith( + expect(messengerMock.call).toHaveBeenCalledTimes(1); + expect(messengerMock.call).toHaveBeenCalledWith( 'ApprovalController:addRequest', { id: expect.any(String), @@ -973,7 +1159,7 @@ describe('TransactionController', () => { const { transactions } = controller.state; expect(transactions).toHaveLength(expectedTransactionCount); - expect(delayMessengerMock.call).toHaveBeenCalledTimes( + expect(messengerMock.call).toHaveBeenCalledTimes( expectedRequestApprovalCalledTimes, ); }, @@ -1079,6 +1265,70 @@ describe('TransactionController', () => { ); }); + describe('networkClientId exists in the MultichainTrackingHelper', () => { + it('adds unapproved transaction to state when using networkClientId', async () => { + const controller = newController({ + options: { isMultichainEnabled: true }, + }); + const sepoliaTxParams: TransactionParams = { + chainId: ChainId.sepolia, + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }; + + multichainTrackingHelperMock.has.mockReturnValue(true); + + await controller.addTransaction(sepoliaTxParams, { + origin: 'metamask', + actionId: ACTION_ID_MOCK, + networkClientId: 'sepolia', + }); + + const transactionMeta = controller.state.transactions[0]; + + expect(transactionMeta.txParams.from).toStrictEqual( + sepoliaTxParams.from, + ); + expect(transactionMeta.chainId).toStrictEqual(sepoliaTxParams.chainId); + expect(transactionMeta.networkClientId).toBe('sepolia'); + expect(transactionMeta.origin).toBe('metamask'); + }); + + it('adds unapproved transaction with networkClientId and can be updated to submitted', async () => { + const controller = newController({ + approve: true, + options: { isMultichainEnabled: true }, + }); + + multichainTrackingHelperMock.has.mockReturnValue(true); + + const submittedEventListener = jest.fn(); + controller.hub.on('transaction-submitted', submittedEventListener); + + const sepoliaTxParams: TransactionParams = { + chainId: ChainId.sepolia, + from: ACCOUNT_MOCK, + to: ACCOUNT_MOCK, + }; + + const { result } = await controller.addTransaction(sepoliaTxParams, { + origin: 'metamask', + actionId: ACTION_ID_MOCK, + networkClientId: 'sepolia', + }); + + await result; + + const { txParams, status, networkClientId, chainId } = + controller.state.transactions[0]; + expect(submittedEventListener).toHaveBeenCalledTimes(1); + expect(txParams.from).toBe(ACCOUNT_MOCK); + expect(networkClientId).toBe('sepolia'); + expect(chainId).toBe(ChainId.sepolia); + expect(status).toBe(TransactionStatus.submitted); + }); + }); + it('generates initial history', async () => { const controller = newController(); @@ -1090,6 +1340,7 @@ describe('TransactionController', () => { const expectedInitialSnapshot = { actionId: undefined, chainId: expect.any(String), + networkClientId: undefined, dappSuggestedGasFees: undefined, deviceConfirmedOn: undefined, id: expect.any(String), @@ -1110,6 +1361,22 @@ describe('TransactionController', () => { ]); }); + it('only reads the current chain id to filter to initially populate the metadata', async () => { + const getNetworkStateMock = jest.fn().mockReturnValue(MOCK_NETWORK.state); + const controller = newController({ + options: { getNetworkState: getNetworkStateMock }, + }); + + await controller.addTransaction({ + from: ACCOUNT_MOCK, + to: ACCOUNT_MOCK, + }); + + // First call comes from getting the chainId to populate the initial unapproved transaction + // Second call comes from getting the network type to populate the initial gas estimates + expect(getNetworkStateMock).toHaveBeenCalledTimes(2); + }); + describe('adds dappSuggestedGasFees to transaction', () => { it.each([ ['origin is MM', ORIGIN_METAMASK], @@ -1232,12 +1499,10 @@ describe('TransactionController', () => { const firstTransaction = controller.state.transactions[0]; // eslint-disable-next-line jest/prefer-spy-on - NonceTrackerPackage.NonceTracker.prototype.getNonceLock = jest - .fn() - .mockResolvedValue({ - nextNonce: NONCE_MOCK + 1, - releaseLock: () => Promise.resolve(), - }); + multichainTrackingHelperMock.getNonceLock = jest.fn().mockResolvedValue({ + nextNonce: NONCE_MOCK + 1, + releaseLock: () => Promise.resolve(), + }); const { result: secondResult } = await controller.addTransaction({ from: ACCOUNT_MOCK, @@ -1269,8 +1534,8 @@ describe('TransactionController', () => { to: ACCOUNT_MOCK, }); - expect(delayMessengerMock.call).toHaveBeenCalledTimes(1); - expect(delayMessengerMock.call).toHaveBeenCalledWith( + expect(messengerMock.call).toHaveBeenCalledTimes(1); + expect(messengerMock.call).toHaveBeenCalledWith( 'ApprovalController:addRequest', { id: expect.any(String), @@ -1296,7 +1561,7 @@ describe('TransactionController', () => { }, ); - expect(delayMessengerMock.call).toHaveBeenCalledTimes(0); + expect(messengerMock.call).toHaveBeenCalledTimes(0); }); it('calls security provider with transaction meta and sets response in to securityProviderResponse', async () => { @@ -1348,7 +1613,9 @@ describe('TransactionController', () => { expect(updateGasMock).toHaveBeenCalledTimes(1); expect(updateGasMock).toHaveBeenCalledWith({ ethQuery: expect.any(Object), - providerConfig: MOCK_NETWORK.state.providerConfig, + chainId: MOCK_NETWORK.state.providerConfig.chainId, + isCustomNetwork: + MOCK_NETWORK.state.providerConfig.type === NetworkType.rpc, txMeta: expect.any(Object), }); }); @@ -1365,8 +1632,9 @@ describe('TransactionController', () => { expect(updateGasFeesMock).toHaveBeenCalledWith({ eip1559: true, ethQuery: expect.any(Object), - getSavedGasFees: expect.any(Function), + gasFeeFlows: [lineaGasFeeFlowMock, defaultGasFeeFlowMock], getGasFeeEstimates: expect.any(Function), + getSavedGasFees: expect.any(Function), txMeta: expect.any(Object), }); }); @@ -1518,7 +1786,7 @@ describe('TransactionController', () => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - const callMock = delayMessengerMock.call as jest.MockedFunction; + const callMock = messengerMock.call as jest.MockedFunction; callMock.mockImplementationOnce(() => { throw new Error('Unknown problem'); }); @@ -1539,7 +1807,7 @@ describe('TransactionController', () => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - const callMock = delayMessengerMock.call as jest.MockedFunction; + const callMock = messengerMock.call as jest.MockedFunction; callMock.mockImplementationOnce(() => { throw new Error('TestError'); }); @@ -1560,7 +1828,7 @@ describe('TransactionController', () => { // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - const callMock = delayMessengerMock.call as jest.MockedFunction; + const callMock = messengerMock.call as jest.MockedFunction; callMock.mockImplementationOnce(() => { controller.state.transactions = []; throw new Error('Unknown problem'); @@ -1878,6 +2146,73 @@ describe('TransactionController', () => { expect(controller.state.transactions).toHaveLength(1); }); + it('should throw error if transaction already confirmed', async () => { + const controller = newController(); + + controller.state.transactions.push({ + id: '2', + chainId: toHex(5), + status: TransactionStatus.submitted, + type: TransactionType.cancel, + time: 123456789, + txParams: { + from: ACCOUNT_MOCK, + }, + }); + + mockSendRawTransaction.mockImplementationOnce( + (_transaction, callback) => { + callback( + undefined, + // eslint-disable-next-line prefer-promise-reject-errors + Promise.reject({ + message: 'nonce too low', + }), + ); + }, + ); + + await expect(controller.stopTransaction('2')).rejects.toThrow( + 'Previous transaction is already confirmed', + ); + + // Expect cancel transaction to be submitted - it will fail + expect(mockSendRawTransaction).toHaveBeenCalledTimes(1); + expect(controller.state.transactions).toHaveLength(1); + }); + + it('should throw error if publish transaction fails', async () => { + const errorMock = new Error('Another reason'); + const controller = newController(); + + controller.state.transactions.push({ + id: '2', + chainId: toHex(5), + status: TransactionStatus.submitted, + type: TransactionType.cancel, + time: 123456789, + txParams: { + from: ACCOUNT_MOCK, + }, + }); + + mockSendRawTransaction.mockImplementationOnce( + (_transaction, callback) => { + callback( + undefined, + // eslint-disable-next-line prefer-promise-reject-errors + Promise.reject(errorMock), + ); + }, + ); + + await expect(controller.stopTransaction('2')).rejects.toThrow(errorMock); + + // Expect cancel transaction to be submitted - it will fail + expect(mockSendRawTransaction).toHaveBeenCalledTimes(1); + expect(controller.state.transactions).toHaveLength(1); + }); + it('submits a cancel transaction', async () => { const simpleSendTransactionId = 'simpleeb1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'; @@ -2098,6 +2433,75 @@ describe('TransactionController', () => { expect(controller.state.transactions).toHaveLength(1); }); + it('should throw error if transaction already confirmed', async () => { + const controller = newController(); + + controller.state.transactions.push({ + id: '2', + chainId: toHex(5), + status: TransactionStatus.submitted, + type: TransactionType.retry, + time: 123456789, + txParams: { + from: ACCOUNT_MOCK, + }, + }); + + mockSendRawTransaction.mockImplementationOnce( + (_transaction, callback) => { + callback( + undefined, + // eslint-disable-next-line prefer-promise-reject-errors + Promise.reject({ + message: 'nonce too low', + }), + ); + }, + ); + + await expect(controller.speedUpTransaction('2')).rejects.toThrow( + 'Previous transaction is already confirmed', + ); + + // Expect speedup transaction to be submitted - it will fail + expect(mockSendRawTransaction).toHaveBeenCalledTimes(1); + expect(controller.state.transactions).toHaveLength(1); + }); + + it('should throw error if publish transaction fails', async () => { + const controller = newController(); + const errorMock = new Error('Another reason'); + + controller.state.transactions.push({ + id: '2', + chainId: toHex(5), + status: TransactionStatus.submitted, + type: TransactionType.retry, + time: 123456789, + txParams: { + from: ACCOUNT_MOCK, + }, + }); + + mockSendRawTransaction.mockImplementationOnce( + (_transaction, callback) => { + callback( + undefined, + // eslint-disable-next-line prefer-promise-reject-errors + Promise.reject(errorMock), + ); + }, + ); + + await expect(controller.speedUpTransaction('2')).rejects.toThrow( + errorMock, + ); + + // Expect speedup transaction to be submitted - it will fail + expect(mockSendRawTransaction).toHaveBeenCalledTimes(1); + expect(controller.state.transactions).toHaveLength(1); + }); + it('creates additional transaction with increased gas', async () => { const controller = newController({ network: MOCK_LINEA_MAINNET_NETWORK, @@ -2316,20 +2720,6 @@ describe('TransactionController', () => { }); }); - describe('getNonceLock', () => { - it('gets the next nonce according to the nonce-tracker', async () => { - const controller = newController({ - network: MOCK_LINEA_MAINNET_NETWORK, - }); - - const { nextNonce } = await controller.getNonceLock(ACCOUNT_MOCK); - - expect(getNonceLockSpy).toHaveBeenCalledTimes(1); - expect(getNonceLockSpy).toHaveBeenCalledWith(ACCOUNT_MOCK); - expect(nextNonce).toBe(NONCE_MOCK); - }); - }); - describe('confirmExternalTransaction', () => { it('adds external transaction to the state as confirmed', async () => { const controller = newController(); @@ -2440,7 +2830,7 @@ describe('TransactionController', () => { ]); }); - it('marks the same nonce local transactions statuses as dropped and defines replacedBy properties', async () => { + it('marks local transactions with the same nonce and chainId as status dropped and defines replacedBy properties', async () => { const droppedEventListener = jest.fn(); const changedStatusEventListener = jest.fn(); const controller = newController({ @@ -2475,7 +2865,7 @@ describe('TransactionController', () => { }; const externalBaseFeePerGas = '0x14'; - // Local unapproved transaction + // Local unapproved transaction with the same chainId and nonce const localTransactionIdWithSameNonce = '9'; controller.state.transactions.push({ id: localTransactionIdWithSameNonce, @@ -2522,7 +2912,7 @@ describe('TransactionController', () => { }); }); - it('doesnt mark transaction as dropped if same nonce local transaction status is failed', async () => { + it('doesnt mark transaction as dropped if local transaction with same nonce and chainId has status of failed', async () => { const controller = newController(); const externalTransactionId = '1'; const externalTransactionHash = '0x1'; @@ -2545,7 +2935,7 @@ describe('TransactionController', () => { }; const externalBaseFeePerGas = '0x14'; - // Off-chain failed local transaction + // Off-chain failed local transaction with the same chainId and nonce const localTransactionIdWithSameNonce = '9'; controller.state.transactions.push({ id: localTransactionIdWithSameNonce, @@ -2660,7 +3050,7 @@ describe('TransactionController', () => { from: ACCOUNT_MOCK, to: ACCOUNT_2_MOCK, id: '1', - chainId: toHex(1), + chainId: toHex(5), status: TransactionStatus.confirmed, txParams: { gasUsed: undefined, @@ -2688,6 +3078,55 @@ describe('TransactionController', () => { transactionMeta: externalTransaction, }); }); + + it('emits confirmed event with transaction chainId regardless of whether it matches globally selected chainId', async () => { + const mockGloballySelectedNetwork = { + ...MOCK_NETWORK, + state: { + ...MOCK_NETWORK.state, + providerConfig: { + type: NetworkType.sepolia, + chainId: ChainId.sepolia, + ticker: NetworksTicker.sepolia, + }, + }, + }; + const controller = newController({ + network: mockGloballySelectedNetwork, + }); + + const confirmedEventListener = jest.fn(); + + controller.hub.on('transaction-confirmed', confirmedEventListener); + + const externalTransactionToConfirm = { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + id: '1', + chainId: ChainId.goerli, // doesn't match globally selected chainId (which is sepolia) + status: TransactionStatus.confirmed, + txParams: { + gasUsed: undefined, + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + const externalTransactionReceipt = { + gasUsed: '0x5208', + }; + const externalBaseFeePerGas = '0x14'; + + await controller.confirmExternalTransaction( + externalTransactionToConfirm, + externalTransactionReceipt, + externalBaseFeePerGas, + ); + + const [[{ transactionMeta }]] = confirmedEventListener.mock.calls; + expect(transactionMeta.chainId).toBe(ChainId.goerli); + }); }); describe('updateTransactionSendFlowHistory', () => { @@ -3455,6 +3894,7 @@ describe('TransactionController', () => { gas: '0x222', to: ACCOUNT_2_MOCK, value: '0x1', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; await expect( @@ -3484,6 +3924,7 @@ describe('TransactionController', () => { gas: '0x5208', to: ACCOUNT_2_MOCK, value: '0x0', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; // Send the transaction to put it in the process of being signed @@ -3514,6 +3955,7 @@ describe('TransactionController', () => { gas: '0x111', to: ACCOUNT_2_MOCK, value: '0x0', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; const mockTransactionParam2 = { from: ACCOUNT_MOCK, @@ -3521,6 +3963,7 @@ describe('TransactionController', () => { gas: '0x222', to: ACCOUNT_2_MOCK, value: '0x1', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; const result = await controller.approveTransactionsWithSameNonce([ @@ -3551,6 +3994,7 @@ describe('TransactionController', () => { gas: '0x111', to: ACCOUNT_2_MOCK, value: '0x0', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; const mockTransactionParam2 = { from: ACCOUNT_MOCK, @@ -3558,6 +4002,7 @@ describe('TransactionController', () => { gas: '0x222', to: ACCOUNT_2_MOCK, value: '0x1', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; await expect( @@ -3569,10 +4014,6 @@ describe('TransactionController', () => { }); it('does not create nonce lock if hasNonce set', async () => { - const getNonceLockMock = jest - .spyOn(NonceTrackerPackage.NonceTracker.prototype, 'getNonceLock') - .mockImplementation(); - const controller = newController(); const mockTransactionParam = { @@ -3581,6 +4022,7 @@ describe('TransactionController', () => { gas: '0x111', to: ACCOUNT_2_MOCK, value: '0x0', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; const mockTransactionParam2 = { @@ -3589,6 +4031,7 @@ describe('TransactionController', () => { gas: '0x222', to: ACCOUNT_2_MOCK, value: '0x1', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; await controller.approveTransactionsWithSameNonce( @@ -3596,7 +4039,36 @@ describe('TransactionController', () => { { hasNonce: true }, ); - expect(getNonceLockMock).not.toHaveBeenCalled(); + expect(getNonceLockSpy).not.toHaveBeenCalled(); + }); + + it('uses the nonceTracker for the networkClientId matching the chainId', async () => { + const controller = newController(); + + const mockTransactionParam = { + from: ACCOUNT_MOCK, + nonce: '0x1', + gas: '0x111', + to: ACCOUNT_2_MOCK, + value: '0x0', + chainId: MOCK_NETWORK.state.providerConfig.chainId, + }; + + const mockTransactionParam2 = { + from: ACCOUNT_MOCK, + nonce: '0x1', + gas: '0x222', + to: ACCOUNT_2_MOCK, + value: '0x1', + chainId: MOCK_NETWORK.state.providerConfig.chainId, + }; + + await controller.approveTransactionsWithSameNonce([ + mockTransactionParam, + mockTransactionParam2, + ]); + + expect(getNonceLockSpy).toHaveBeenCalledWith(ACCOUNT_MOCK, 'goerli'); }); }); @@ -4079,8 +4551,8 @@ describe('TransactionController', () => { controller.initApprovals(); await flushPromises(); - expect(delayMessengerMock.call).toHaveBeenCalledTimes(2); - expect(delayMessengerMock.call).toHaveBeenCalledWith( + expect(messengerMock.call).toHaveBeenCalledTimes(2); + expect(messengerMock.call).toHaveBeenCalledWith( 'ApprovalController:addRequest', { expectsResult: true, @@ -4091,7 +4563,7 @@ describe('TransactionController', () => { }, false, ); - expect(delayMessengerMock.call).toHaveBeenCalledWith( + expect(messengerMock.call).toHaveBeenCalledWith( 'ApprovalController:addRequest', { expectsResult: true, @@ -4104,6 +4576,59 @@ describe('TransactionController', () => { ); }); + it('only reads the current chain id to filter for unapproved transactions', async () => { + const mockTransactionMeta = { + from: ACCOUNT_MOCK, + chainId: toHex(5), + status: TransactionStatus.unapproved, + txParams: { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + }; + + const mockedTransactions = [ + { + id: '123', + ...mockTransactionMeta, + history: [{ ...mockTransactionMeta, id: '123' }], + }, + { + id: '1234', + ...mockTransactionMeta, + history: [{ ...mockTransactionMeta, id: '1234' }], + }, + { + id: '12345', + ...mockTransactionMeta, + history: [{ ...mockTransactionMeta, id: '12345' }], + isUserOperation: true, + }, + ]; + + const mockedControllerState = { + transactions: mockedTransactions, + methodData: {}, + lastFetchedBlockNumbers: {}, + }; + + const getNetworkStateMock = jest + .fn() + .mockReturnValue(MOCK_NETWORK.state); + + const controller = newController({ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: mockedControllerState as any, + options: { getNetworkState: getNetworkStateMock }, + }); + + controller.initApprovals(); + await flushPromises(); + + expect(getNetworkStateMock).toHaveBeenCalledTimes(1); + }); + it('catches error without code property in error object while creating approval', async () => { const mockTransactionMeta = { from: ACCOUNT_MOCK, @@ -4134,12 +4659,18 @@ describe('TransactionController', () => { lastFetchedBlockNumbers: {}, }; + const controller = newController({ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: mockedControllerState as any, + }); + const mockedErrorMessage = 'mocked error'; // Expect both calls to throw error, one with code property to check if it is handled // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - (delayMessengerMock.call as jest.MockedFunction) + (messengerMock.call as jest.MockedFunction) .mockImplementationOnce(() => { // eslint-disable-next-line @typescript-eslint/no-throw-literal throw { message: mockedErrorMessage }; @@ -4153,12 +4684,6 @@ describe('TransactionController', () => { }); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - const controller = newController({ - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - state: mockedControllerState as any, - }); - controller.initApprovals(); await flushPromises(); @@ -4168,14 +4693,14 @@ describe('TransactionController', () => { 'Error during persisted transaction approval', new Error(mockedErrorMessage), ); - expect(delayMessengerMock.call).toHaveBeenCalledTimes(2); + expect(messengerMock.call).toHaveBeenCalledTimes(2); }); it('does not create any approval when there is no unapproved transaction', async () => { const controller = newController(); controller.initApprovals(); await flushPromises(); - expect(delayMessengerMock.call).not.toHaveBeenCalled(); + expect(messengerMock.call).not.toHaveBeenCalled(); }); }); diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 63a2c70cb2..2b1efd48df 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -1,6 +1,7 @@ import { Hardfork, Common, type ChainConfig } from '@ethereumjs/common'; import type { TypedTransaction } from '@ethereumjs/tx'; import { TransactionFactory } from '@ethereumjs/tx'; +import { bufferToHex } from '@ethereumjs/util'; import type { AcceptResultCallbacks, AddApprovalRequest, @@ -15,7 +16,6 @@ import { BaseControllerV1 } from '@metamask/base-controller'; import { query, NetworkType, - RPC, ApprovalType, ORIGIN_METAMASK, convertHexToDecimal, @@ -24,14 +24,20 @@ import EthQuery from '@metamask/eth-query'; import type { GasFeeState } from '@metamask/gas-fee-controller'; import type { BlockTracker, + NetworkClientId, + NetworkController, + NetworkControllerStateChangeEvent, NetworkState, Provider, + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, } from '@metamask/network-controller'; +import { NetworkClientType } from '@metamask/network-controller'; import { errorCodes, rpcErrors, providerErrors } from '@metamask/rpc-errors'; import type { Hex } from '@metamask/utils'; +import { add0x } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import { MethodRegistry } from 'eth-method-registry'; -import { addHexPrefix, bufferToHex } from 'ethereumjs-util'; import { EventEmitter } from 'events'; import { mapValues, merge, pickBy, sortBy } from 'lodash'; import { NonceTracker } from 'nonce-tracker'; @@ -41,8 +47,13 @@ import type { } from 'nonce-tracker'; import { v1 as random } from 'uuid'; +import { DefaultGasFeeFlow } from './gas-flows/DefaultGasFeeFlow'; +import { LineaGasFeeFlow } from './gas-flows/LineaGasFeeFlow'; import { EtherscanRemoteTransactionSource } from './helpers/EtherscanRemoteTransactionSource'; +import { GasFeePoller } from './helpers/GasFeePoller'; +import type { IncomingTransactionOptions } from './helpers/IncomingTransactionHelper'; import { IncomingTransactionHelper } from './helpers/IncomingTransactionHelper'; +import { MultichainTrackingHelper } from './helpers/MultichainTrackingHelper'; import { PendingTransactionTracker } from './helpers/PendingTransactionTracker'; import { projectLogger as log } from './logger'; import type { @@ -56,6 +67,7 @@ import type { TransactionReceipt, WalletDevice, SecurityAlertResponse, + GasFeeFlow, } from './types'; import { TransactionEnvelopeType, @@ -98,7 +110,9 @@ import { export const HARDFORK = Hardfork.London; /** - * @type Result + * Object with new transaction's meta and a promise resolving to the + * transaction hash if successful. + * * @property result - Promise resolving to a new transaction hash * @property transactionMeta - Meta information about this new transaction */ @@ -126,9 +140,8 @@ export interface FeeMarketEIP1559Values { } /** - * @type TransactionConfig - * * Transaction controller configuration + * * @property provider - Provider used to create a new underlying EthQuery instance * @property sign - Method used to sign transactions */ @@ -143,9 +156,8 @@ export interface TransactionConfig extends BaseConfig { } /** - * @type MethodData - * * Method data registry object + * * @property registryMethod - Registry method raw string * @property parsedRegistryMethod - Registry method object, containing name and method arguments */ @@ -158,9 +170,8 @@ export interface MethodData { } /** - * @type TransactionState - * * Transaction controller state + * * @property transactions - A list of TransactionMeta objects * @property methodData - Object containing all known method data information */ @@ -176,13 +187,96 @@ export interface TransactionState extends BaseState { /** * Multiplier used to determine a transaction's increased gas fee during cancellation */ -export const CANCEL_RATE = 1.5; +export const CANCEL_RATE = 1.1; /** * Multiplier used to determine a transaction's increased gas fee during speed up */ export const SPEED_UP_RATE = 1.1; +/** + * Configuration options for the PendingTransactionTracker + * + * @property isResubmitEnabled - Whether transaction publishing is automatically retried. + */ +export type PendingTransactionOptions = { + isResubmitEnabled?: boolean; +}; + +/** + * TransactionController constructor options. + * + * @property blockTracker - The block tracker used to poll for new blocks data. + * @property disableHistory - Whether to disable storing history in transaction metadata. + * @property disableSendFlowHistory - Explicitly disable transaction metadata history. + * @property disableSwaps - Whether to disable additional processing on swaps transactions. + * @property isMultichainEnabled - Enable multichain support. + * @property getCurrentAccountEIP1559Compatibility - Whether or not the account supports EIP-1559. + * @property getCurrentNetworkEIP1559Compatibility - Whether or not the network supports EIP-1559. + * @property getExternalPendingTransactions - Callback to retrieve pending transactions from external sources. + * @property getGasFeeEstimates - Callback to retrieve gas fee estimates. + * @property getNetworkClientRegistry - Gets the network client registry. + * @property getNetworkState - Gets the state of the network controller. + * @property getPermittedAccounts - Get accounts that a given origin has permissions for. + * @property getSavedGasFees - Gets the saved gas fee config. + * @property getSelectedAddress - Gets the address of the currently selected account. + * @property incomingTransactions - Configuration options for incoming transaction support. + * @property messenger - The controller messenger. + * @property onNetworkStateChange - Allows subscribing to network controller state changes. + * @property pendingTransactions - Configuration options for pending transaction support. + * @property provider - The provider used to create the underlying EthQuery instance. + * @property securityProviderRequest - A function for verifying a transaction, whether it is malicious or not. + * @property hooks - The controller hooks. + * @property hooks.afterSign - Additional logic to execute after signing a transaction. Return false to not change the status to signed. + * @property hooks.beforeApproveOnInit - Additional logic to execute before starting an approval flow for a transaction during initialization. Return false to skip the transaction. + * @property hooks.beforeCheckPendingTransaction - Additional logic to execute before checking pending transactions. Return false to prevent the broadcast of the transaction. + * @property hooks.beforePublish - Additional logic to execute before publishing a transaction. Return false to prevent the broadcast of the transaction. + * @property hooks.getAdditionalSignArguments - Returns additional arguments required to sign a transaction. + * @property hooks.publish - Alternate logic to publish a transaction. + */ +export type TransactionControllerOptions = { + blockTracker: BlockTracker; + disableHistory: boolean; + disableSendFlowHistory: boolean; + disableSwaps: boolean; + getCurrentAccountEIP1559Compatibility?: () => Promise; + getCurrentNetworkEIP1559Compatibility: () => Promise; + getExternalPendingTransactions?: ( + address: string, + chainId?: string, + ) => NonceTrackerTransaction[]; + getGasFeeEstimates?: () => Promise; + getNetworkState: () => NetworkState; + getPermittedAccounts: (origin?: string) => Promise; + getSavedGasFees?: (chainId: Hex) => SavedGasFees | undefined; + getSelectedAddress: () => string; + incomingTransactions?: IncomingTransactionOptions; + messenger: TransactionControllerMessenger; + onNetworkStateChange: (listener: (state: NetworkState) => void) => void; + pendingTransactions?: PendingTransactionOptions; + provider: Provider; + securityProviderRequest?: SecurityProviderRequest; + getNetworkClientRegistry: NetworkController['getNetworkClientRegistry']; + isMultichainEnabled: boolean; + hooks: { + afterSign?: ( + transactionMeta: TransactionMeta, + signedTx: TypedTransaction, + ) => boolean; + beforeApproveOnInit?: (transactionMeta: TransactionMeta) => boolean; + beforeCheckPendingTransaction?: ( + transactionMeta: TransactionMeta, + ) => boolean; + beforePublish?: (transactionMeta: TransactionMeta) => boolean; + getAdditionalSignArguments?: ( + transactionMeta: TransactionMeta, + ) => (TransactionMeta | undefined)[]; + publish?: ( + transactionMeta: TransactionMeta, + ) => Promise<{ transactionHash: string }>; + }; +}; + /** * The name of the {@link TransactionController}. */ @@ -191,7 +285,12 @@ const controllerName = 'TransactionController'; /** * The external actions available to the {@link TransactionController}. */ -type AllowedActions = AddApprovalRequest; +type AllowedActions = + | AddApprovalRequest + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetNetworkClientByIdAction; + +type AllowedEvents = NetworkControllerStateChangeEvent; /** * The messenger of the {@link TransactionController}. @@ -199,9 +298,9 @@ type AllowedActions = AddApprovalRequest; export type TransactionControllerMessenger = RestrictedControllerMessenger< typeof controllerName, AllowedActions, - never, + AllowedEvents, AllowedActions['type'], - never + AllowedEvents['type'] >; // This interface was created before this ESLint rule was added. @@ -223,8 +322,6 @@ export class TransactionController extends BaseControllerV1< TransactionConfig, TransactionState > { - private readonly ethQuery: EthQuery; - private readonly isHistoryDisabled: boolean; private readonly isSwapsDisabled: boolean; @@ -239,17 +336,19 @@ export class TransactionController extends BaseControllerV1< // eslint-disable-next-line @typescript-eslint/no-explicit-any private readonly registry: any; - private readonly provider: Provider; - private readonly mutex = new Mutex(); + private readonly gasFeeFlows: GasFeeFlow[]; + private readonly getSavedGasFees: (chainId: Hex) => SavedGasFees | undefined; private readonly getNetworkState: () => NetworkState; private readonly getCurrentAccountEIP1559Compatibility: () => Promise; - private readonly getCurrentNetworkEIP1559Compatibility: () => Promise; + private readonly getCurrentNetworkEIP1559Compatibility: ( + networkClientId?: NetworkClientId, + ) => Promise; private readonly getGasFeeEstimates: () => Promise; @@ -259,19 +358,20 @@ export class TransactionController extends BaseControllerV1< private readonly getExternalPendingTransactions: ( address: string, + chainId?: string, ) => NonceTrackerTransaction[]; private readonly messagingSystem: TransactionControllerMessenger; + readonly #incomingTransactionOptions: IncomingTransactionOptions; + private readonly incomingTransactionHelper: IncomingTransactionHelper; private readonly securityProviderRequest?: SecurityProviderRequest; - private readonly pendingTransactionTracker: PendingTransactionTracker; - - private readonly cancelMultiplier: number; + readonly #pendingTransactionOptions: PendingTransactionOptions; - private readonly speedUpMultiplier: number; + private readonly pendingTransactionTracker: PendingTransactionTracker; private readonly signAbortCallbacks: Map void> = new Map(); @@ -328,6 +428,8 @@ export class TransactionController extends BaseControllerV1< return { registryMethod, parsedRegistryMethod }; } + #multichainTrackingHelper: MultichainTrackingHelper; + /** * EventEmitter instance used to listen to specific transactional events */ @@ -347,49 +449,9 @@ export class TransactionController extends BaseControllerV1< transactionMeta?: TransactionMeta, ) => Promise; - /** - * Creates a TransactionController instance. - * - * @param options - The controller options. - * @param options.blockTracker - The block tracker used to poll for new blocks data. - * @param options.cancelMultiplier - Multiplier used to determine a transaction's increased gas fee during cancellation. - * @param options.disableHistory - Whether to disable storing history in transaction metadata. - * @param options.disableSendFlowHistory - Explicitly disable transaction metadata history. - * @param options.disableSwaps - Whether to disable additional processing on swaps transactions. - * @param options.getCurrentAccountEIP1559Compatibility - Whether or not the account supports EIP-1559. - * @param options.getCurrentNetworkEIP1559Compatibility - Whether or not the network supports EIP-1559. - * @param options.getExternalPendingTransactions - Callback to retrieve pending transactions from external sources. - * @param options.getGasFeeEstimates - Callback to retrieve gas fee estimates. - * @param options.getNetworkState - Gets the state of the network controller. - * @param options.getPermittedAccounts - Get accounts that a given origin has permissions for. - * @param options.getSavedGasFees - Gets the saved gas fee config. - * @param options.getSelectedAddress - Gets the address of the currently selected account. - * @param options.incomingTransactions - Configuration options for incoming transaction support. - * @param options.incomingTransactions.includeTokenTransfers - Whether or not to include ERC20 token transfers. - * @param options.incomingTransactions.isEnabled - Whether or not incoming transaction retrieval is enabled. - * @param options.incomingTransactions.queryEntireHistory - Whether to initially query the entire transaction history or only recent blocks. - * @param options.incomingTransactions.updateTransactions - Whether to update local transactions using remote transaction data. - * @param options.messenger - The controller messenger. - * @param options.onNetworkStateChange - Allows subscribing to network controller state changes. - * @param options.pendingTransactions - Configuration options for pending transaction support. - * @param options.pendingTransactions.isResubmitEnabled - Whether transaction publishing is automatically retried. - * @param options.provider - The provider used to create the underlying EthQuery instance. - * @param options.securityProviderRequest - A function for verifying a transaction, whether it is malicious or not. - * @param options.speedUpMultiplier - Multiplier used to determine a transaction's increased gas fee during speed up. - * @param options.hooks - The controller hooks. - * @param options.hooks.afterSign - Additional logic to execute after signing a transaction. Return false to not change the status to signed. - * @param options.hooks.beforeApproveOnInit - Additional logic to execute before starting an approval flow for a transaction during initialization. Return false to skip the transaction. - * @param options.hooks.beforeCheckPendingTransaction - Additional logic to execute before checking pending transactions. Return false to prevent the broadcast of the transaction. - * @param options.hooks.beforePublish - Additional logic to execute before publishing a transaction. Return false to prevent the broadcast of the transaction. - * @param options.hooks.getAdditionalSignArguments - Returns additional arguments required to sign a transaction. - * @param options.hooks.publish - Alternate logic to publish a transaction. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. - */ constructor( { blockTracker, - cancelMultiplier, disableHistory, disableSendFlowHistory, disableSwaps, @@ -407,56 +469,10 @@ export class TransactionController extends BaseControllerV1< pendingTransactions = {}, provider, securityProviderRequest, - speedUpMultiplier, - hooks = {}, - }: { - blockTracker: BlockTracker; - cancelMultiplier?: number; - disableHistory: boolean; - disableSendFlowHistory: boolean; - disableSwaps: boolean; - getCurrentAccountEIP1559Compatibility?: () => Promise; - getCurrentNetworkEIP1559Compatibility: () => Promise; - getExternalPendingTransactions?: ( - address: string, - ) => NonceTrackerTransaction[]; - getGasFeeEstimates?: () => Promise; - getNetworkState: () => NetworkState; - getPermittedAccounts: (origin?: string) => Promise; - getSavedGasFees?: (chainId: Hex) => SavedGasFees | undefined; - getSelectedAddress: () => string; - incomingTransactions?: { - includeTokenTransfers?: boolean; - isEnabled?: () => boolean; - queryEntireHistory?: boolean; - updateTransactions?: boolean; - }; - messenger: TransactionControllerMessenger; - onNetworkStateChange: (listener: (state: NetworkState) => void) => void; - pendingTransactions?: { - isResubmitEnabled?: boolean; - }; - provider: Provider; - securityProviderRequest?: SecurityProviderRequest; - speedUpMultiplier?: number; - hooks: { - afterSign?: ( - transactionMeta: TransactionMeta, - signedTx: TypedTransaction, - ) => boolean; - beforeApproveOnInit?: (transactionMeta: TransactionMeta) => boolean; - beforeCheckPendingTransaction?: ( - transactionMeta: TransactionMeta, - ) => boolean; - beforePublish?: (transactionMeta: TransactionMeta) => boolean; - getAdditionalSignArguments?: ( - transactionMeta: TransactionMeta, - ) => (TransactionMeta | undefined)[]; - publish?: ( - transactionMeta: TransactionMeta, - ) => Promise<{ transactionHash: string }>; - }; - }, + getNetworkClientRegistry, + isMultichainEnabled = false, + hooks, + }: TransactionControllerOptions, config?: Partial, state?: Partial, ) { @@ -471,13 +487,9 @@ export class TransactionController extends BaseControllerV1< transactions: [], lastFetchedBlockNumbers: {}, }; - this.initialize(); - - this.provider = provider; this.messagingSystem = messenger; this.getNetworkState = getNetworkState; - this.ethQuery = new EthQuery(provider); this.isSendFlowHistoryDisabled = disableSendFlowHistory ?? false; this.isHistoryDisabled = disableHistory ?? false; this.isSwapsDisabled = disableSwaps ?? false; @@ -495,8 +507,8 @@ export class TransactionController extends BaseControllerV1< this.getExternalPendingTransactions = getExternalPendingTransactions ?? (() => []); this.securityProviderRequest = securityProviderRequest; - this.cancelMultiplier = cancelMultiplier ?? CANCEL_RATE; - this.speedUpMultiplier = speedUpMultiplier ?? SPEED_UP_RATE; + this.#incomingTransactionOptions = incomingTransactions; + this.#pendingTransactionOptions = pendingTransactions; this.afterSign = hooks?.afterSign ?? (() => true); this.beforeApproveOnInit = hooks?.beforeApproveOnInit ?? (() => true); @@ -510,73 +522,105 @@ export class TransactionController extends BaseControllerV1< this.publish = hooks?.publish ?? (() => Promise.resolve({ transactionHash: undefined })); - this.nonceTracker = new NonceTracker({ - // @ts-expect-error provider types misaligned: SafeEventEmitterProvider vs Record + this.nonceTracker = this.#createNonceTracker({ provider, blockTracker, - getPendingTransactions: - this.getNonceTrackerPendingTransactions.bind(this), - getConfirmedTransactions: this.getNonceTrackerTransactions.bind( - this, - TransactionStatus.confirmed, - ), }); - this.incomingTransactionHelper = new IncomingTransactionHelper({ - blockTracker, - getCurrentAccount: getSelectedAddress, - getLastFetchedBlockNumbers: () => this.state.lastFetchedBlockNumbers, - getNetworkState, - isEnabled: incomingTransactions.isEnabled, - queryEntireHistory: incomingTransactions.queryEntireHistory, - remoteTransactionSource: new EtherscanRemoteTransactionSource({ - includeTokenTransfers: incomingTransactions.includeTokenTransfers, - }), - transactionLimit: this.config.txHistoryLimit, - updateTransactions: incomingTransactions.updateTransactions, + this.#multichainTrackingHelper = new MultichainTrackingHelper({ + isMultichainEnabled, + provider, + nonceTracker: this.nonceTracker, + incomingTransactionOptions: incomingTransactions, + findNetworkClientIdByChainId: (chainId: Hex) => { + return this.messagingSystem.call( + `NetworkController:findNetworkClientIdByChainId`, + chainId, + ); + }, + getNetworkClientById: ((networkClientId: NetworkClientId) => { + return this.messagingSystem.call( + `NetworkController:getNetworkClientById`, + networkClientId, + ); + }) as NetworkController['getNetworkClientById'], + getNetworkClientRegistry, + removeIncomingTransactionHelperListeners: + this.#removeIncomingTransactionHelperListeners.bind(this), + removePendingTransactionTrackerListeners: + this.#removePendingTransactionTrackerListeners.bind(this), + createNonceTracker: this.#createNonceTracker.bind(this), + createIncomingTransactionHelper: + this.#createIncomingTransactionHelper.bind(this), + createPendingTransactionTracker: + this.#createPendingTransactionTracker.bind(this), + onNetworkStateChange: (listener) => { + this.messagingSystem.subscribe( + 'NetworkController:stateChange', + listener, + ); + }, }); + this.#multichainTrackingHelper.initialize(); - this.incomingTransactionHelper.hub.on( - 'transactions', - this.onIncomingTransactions.bind(this), - ); + const etherscanRemoteTransactionSource = + new EtherscanRemoteTransactionSource({ + includeTokenTransfers: incomingTransactions.includeTokenTransfers, + }); - this.incomingTransactionHelper.hub.on( - 'updatedLastFetchedBlockNumbers', - this.onUpdatedLastFetchedBlockNumbers.bind(this), - ); + this.incomingTransactionHelper = this.#createIncomingTransactionHelper({ + blockTracker, + etherscanRemoteTransactionSource, + }); - this.pendingTransactionTracker = new PendingTransactionTracker({ - approveTransaction: this.approveTransaction.bind(this), + this.pendingTransactionTracker = this.#createPendingTransactionTracker({ + provider, blockTracker, - getChainId: this.getChainId.bind(this), - getEthQuery: () => this.ethQuery, + }); + + this.gasFeeFlows = this.#getGasFeeFlows(); + + const gasFeePoller = new GasFeePoller({ + // Default gas fee polling is not yet supported by the clients + gasFeeFlows: this.gasFeeFlows.slice(0, -1), + getEthQuery: (chainId, networkClientId) => + this.#multichainTrackingHelper.getEthQuery({ + networkClientId, + chainId, + }), + getGasFeeControllerEstimates: this.getGasFeeEstimates, getTransactions: () => this.state.transactions, - isResubmitEnabled: pendingTransactions.isResubmitEnabled, - nonceTracker: this.nonceTracker, onStateChange: (listener) => { this.subscribe(listener); - onNetworkStateChange(listener); - listener(); - }, - publishTransaction: this.publishTransaction.bind(this), - hooks: { - beforeCheckPendingTransaction: - this.beforeCheckPendingTransaction.bind(this), - beforePublish: this.beforePublish.bind(this), }, }); - this.addPendingTransactionTrackerListeners(); + gasFeePoller.hub.on('transaction-updated', (transactionMeta) => + this.#updateTransactionInternal(transactionMeta, { skipHistory: true }), + ); + // when transactionsController state changes + // check for pending transactions and start polling if there are any + this.subscribe(this.#checkForPendingTransactionAndStartPolling); + + // TODO once v2 is merged make sure this only runs when + // selectedNetworkClientId changes onNetworkStateChange(() => { log('Detected network change', this.getChainId()); + this.pendingTransactionTracker.startIfPendingTransactions(); this.onBootCleanup(); }); this.onBootCleanup(); } + /** + * Stops polling and removes listeners to prepare the controller for garbage collection. + */ + destroy() { + this.#stopAllTracking(); + } + /** * Handle new method data request. * @@ -621,6 +665,7 @@ export class TransactionController extends BaseControllerV1< * @param opts.swaps - Options for swaps transactions. * @param opts.swaps.hasApproveTx - Whether the transaction has an approval transaction. * @param opts.swaps.meta - Metadata for swap transaction. + * @param opts.networkClientId - The id of the network client for this transaction. * @returns Object containing a promise resolving to the transaction hash if approved. */ async addTransaction( @@ -635,6 +680,7 @@ export class TransactionController extends BaseControllerV1< sendFlowHistory, swaps = {}, type, + networkClientId, }: { actionId?: string; deviceConfirmedOn?: WalletDevice; @@ -648,13 +694,24 @@ export class TransactionController extends BaseControllerV1< meta?: Partial; }; type?: TransactionType; + networkClientId?: NetworkClientId; } = {}, ): Promise { log('Adding transaction', txParams); txParams = normalizeTxParams(txParams); + if ( + networkClientId && + !this.#multichainTrackingHelper.has(networkClientId) + ) { + throw new Error( + 'The networkClientId for this transaction could not be found', + ); + } - const isEIP1559Compatible = await this.getEIP1559Compatibility(); + const isEIP1559Compatible = await this.getEIP1559Compatibility( + networkClientId, + ); validateTxParams(txParams, isEIP1559Compatible); @@ -672,11 +729,16 @@ export class TransactionController extends BaseControllerV1< origin, ); + const chainId = this.getChainId(networkClientId); + const ethQuery = this.#multichainTrackingHelper.getEthQuery({ + networkClientId, + chainId, + }); + const transactionType = - type ?? (await determineTransactionType(txParams, this.ethQuery)).type; + type ?? (await determineTransactionType(txParams, ethQuery)).type; const existingTransactionMeta = this.getTransactionWithActionId(actionId); - const chainId = this.getChainId(); // If a request to add a transaction with the same actionId is submitted again, a new transaction will not be created for it. const transactionMeta: TransactionMeta = existingTransactionMeta || { @@ -694,6 +756,7 @@ export class TransactionController extends BaseControllerV1< userEditedGasLimit: false, verifiedOnBlockchain: false, type: transactionType, + networkClientId, }; await this.updateGasProperties(transactionMeta); @@ -739,16 +802,39 @@ export class TransactionController extends BaseControllerV1< }; } - startIncomingTransactionPolling() { - this.incomingTransactionHelper.start(); + startIncomingTransactionPolling(networkClientIds: NetworkClientId[] = []) { + if (networkClientIds.length === 0) { + this.incomingTransactionHelper.start(); + return; + } + this.#multichainTrackingHelper.startIncomingTransactionPolling( + networkClientIds, + ); } - stopIncomingTransactionPolling() { + stopIncomingTransactionPolling(networkClientIds: NetworkClientId[] = []) { + if (networkClientIds.length === 0) { + this.incomingTransactionHelper.stop(); + return; + } + this.#multichainTrackingHelper.stopIncomingTransactionPolling( + networkClientIds, + ); + } + + stopAllIncomingTransactionPolling() { this.incomingTransactionHelper.stop(); + this.#multichainTrackingHelper.stopAllIncomingTransactionPolling(); } - async updateIncomingTransactions() { - await this.incomingTransactionHelper.update(); + async updateIncomingTransactions(networkClientIds: NetworkClientId[] = []) { + if (networkClientIds.length === 0) { + await this.incomingTransactionHelper.update(); + return; + } + await this.#multichainTrackingHelper.updateIncomingTransactions( + networkClientIds, + ); } /** @@ -794,7 +880,7 @@ export class TransactionController extends BaseControllerV1< // gasPrice (legacy non EIP1559) const minGasPrice = getIncreasedPriceFromExisting( transactionMeta.txParams.gasPrice, - this.cancelMultiplier, + CANCEL_RATE, ); const gasPriceFromValues = isGasPriceValue(gasValues) && gasValues.gasPrice; @@ -808,7 +894,7 @@ export class TransactionController extends BaseControllerV1< const existingMaxFeePerGas = transactionMeta.txParams?.maxFeePerGas; const minMaxFeePerGas = getIncreasedPriceFromExisting( existingMaxFeePerGas, - this.cancelMultiplier, + CANCEL_RATE, ); const maxFeePerGasValues = isFeeMarketEIP1559Values(gasValues) && gasValues.maxFeePerGas; @@ -822,7 +908,7 @@ export class TransactionController extends BaseControllerV1< transactionMeta.txParams?.maxPriorityFeePerGas; const minMaxPriorityFeePerGas = getIncreasedPriceFromExisting( existingMaxPriorityFeePerGas, - this.cancelMultiplier, + CANCEL_RATE, ); const maxPriorityFeePerGasValues = isFeeMarketEIP1559Values(gasValues) && gasValues.maxPriorityFeePerGas; @@ -855,7 +941,10 @@ export class TransactionController extends BaseControllerV1< value: '0x0', }; - const unsignedEthTx = this.prepareUnsignedEthTx(newTxParams); + const unsignedEthTx = this.prepareUnsignedEthTx( + transactionMeta.chainId, + newTxParams, + ); const signedTx = await this.sign( unsignedEthTx, @@ -876,11 +965,20 @@ export class TransactionController extends BaseControllerV1< txParams: newTxParams, }); - const hash = await this.publishTransaction(rawTx); + const ethQuery = this.#multichainTrackingHelper.getEthQuery({ + networkClientId: transactionMeta.networkClientId, + chainId: transactionMeta.chainId, + }); + const hash = await this.publishTransactionForRetry( + ethQuery, + rawTx, + transactionMeta, + ); const cancelTransactionMeta: TransactionMeta = { actionId, chainId: transactionMeta.chainId, + networkClientId: transactionMeta.networkClientId, estimatedBaseFee, hash, id: random(), @@ -955,7 +1053,7 @@ export class TransactionController extends BaseControllerV1< // gasPrice (legacy non EIP1559) const minGasPrice = getIncreasedPriceFromExisting( transactionMeta.txParams.gasPrice, - this.speedUpMultiplier, + SPEED_UP_RATE, ); const gasPriceFromValues = isGasPriceValue(gasValues) && gasValues.gasPrice; @@ -969,7 +1067,7 @@ export class TransactionController extends BaseControllerV1< const existingMaxFeePerGas = transactionMeta.txParams?.maxFeePerGas; const minMaxFeePerGas = getIncreasedPriceFromExisting( existingMaxFeePerGas, - this.speedUpMultiplier, + SPEED_UP_RATE, ); const maxFeePerGasValues = isFeeMarketEIP1559Values(gasValues) && gasValues.maxFeePerGas; @@ -983,7 +1081,7 @@ export class TransactionController extends BaseControllerV1< transactionMeta.txParams?.maxPriorityFeePerGas; const minMaxPriorityFeePerGas = getIncreasedPriceFromExisting( existingMaxPriorityFeePerGas, - this.speedUpMultiplier, + SPEED_UP_RATE, ); const maxPriorityFeePerGasValues = isFeeMarketEIP1559Values(gasValues) && gasValues.maxPriorityFeePerGas; @@ -1010,7 +1108,10 @@ export class TransactionController extends BaseControllerV1< gasPrice: newGasPrice, }; - const unsignedEthTx = this.prepareUnsignedEthTx(txParams); + const unsignedEthTx = this.prepareUnsignedEthTx( + transactionMeta.chainId, + txParams, + ); const signedTx = await this.sign( unsignedEthTx, @@ -1028,7 +1129,15 @@ export class TransactionController extends BaseControllerV1< log('Submitting speed up transaction', { oldFee, newFee, txParams }); - const hash = await query(this.ethQuery, 'sendRawTransaction', [rawTx]); + const ethQuery = this.#multichainTrackingHelper.getEthQuery({ + networkClientId: transactionMeta.networkClientId, + chainId: transactionMeta.chainId, + }); + const hash = await this.publishTransactionForRetry( + ethQuery, + rawTx, + transactionMeta, + ); const baseTransactionMeta: TransactionMeta = { ...transactionMeta, @@ -1080,12 +1189,19 @@ export class TransactionController extends BaseControllerV1< * Estimates required gas for a given transaction. * * @param transaction - The transaction to estimate gas for. + * @param networkClientId - The network client id to use for the estimate. * @returns The gas and gas price. */ - async estimateGas(transaction: TransactionParams) { + async estimateGas( + transaction: TransactionParams, + networkClientId?: NetworkClientId, + ) { + const ethQuery = this.#multichainTrackingHelper.getEthQuery({ + networkClientId, + }); const { estimatedGas, simulationFails } = await estimateGas( transaction, - this.ethQuery, + ethQuery, ); return { gas: estimatedGas, simulationFails }; @@ -1096,14 +1212,19 @@ export class TransactionController extends BaseControllerV1< * * @param transaction - The transaction params to estimate gas for. * @param multiplier - The multiplier to use for the gas buffer. + * @param networkClientId - The network client id to use for the estimate. */ async estimateGasBuffered( transaction: TransactionParams, multiplier: number, + networkClientId?: NetworkClientId, ) { + const ethQuery = this.#multichainTrackingHelper.getEthQuery({ + networkClientId, + }); const { blockGasLimit, estimatedGas, simulationFails } = await estimateGas( transaction, - this.ethQuery, + ethQuery, ); const gas = addGasBuffer(estimatedGas, blockGasLimit, multiplier); @@ -1121,15 +1242,10 @@ export class TransactionController extends BaseControllerV1< * @param note - A note or update reason to include in the transaction history. */ updateTransaction(transactionMeta: TransactionMeta, note: string) { - const { transactions } = this.state; - transactionMeta.txParams = normalizeTxParams(transactionMeta.txParams); - validateTxParams(transactionMeta.txParams); - if (!this.isHistoryDisabled) { - updateTransactionHistory(transactionMeta, note); - } - const index = transactions.findIndex(({ id }) => transactionMeta.id === id); - transactions[index] = transactionMeta; - this.update({ transactions: this.trimTransactionsForState(transactions) }); + this.#updateTransactionInternal(transactionMeta, { + note, + skipHistory: this.isHistoryDisabled, + }); } /** @@ -1195,14 +1311,6 @@ export class TransactionController extends BaseControllerV1< }); } - startIncomingTransactionProcessing() { - this.incomingTransactionHelper.start(); - } - - stopIncomingTransactionProcessing() { - this.incomingTransactionHelper.stop(); - } - /** * Adds external provided transaction to state as confirmed transaction. * @@ -1449,15 +1557,14 @@ export class TransactionController extends BaseControllerV1< return this.getTransaction(transactionId) as TransactionMeta; } - /** - * Gets the next nonce according to the nonce-tracker. - * Ensure `releaseLock` is called once processing of the `nonce` value is complete. - * - * @param address - The hex string address for the transaction. - * @returns object with the `nextNonce` `nonceDetails`, and the releaseLock. - */ - async getNonceLock(address: string): Promise { - return this.nonceTracker.getNonceLock(address); + async getNonceLock( + address: string, + networkClientId?: NetworkClientId, + ): Promise { + return this.#multichainTrackingHelper.getNonceLock( + address, + networkClientId, + ); } /** @@ -1518,7 +1625,10 @@ export class TransactionController extends BaseControllerV1< const updatedTransaction = merge(transactionMeta, editableParams); const { type } = await determineTransactionType( updatedTransaction.txParams, - this.ethQuery, + this.#multichainTrackingHelper.getEthQuery({ + networkClientId: transactionMeta.networkClientId, + chainId: transactionMeta.chainId, + }), ); updatedTransaction.type = type; @@ -1538,7 +1648,7 @@ export class TransactionController extends BaseControllerV1< * @returns The raw transactions. */ async approveTransactionsWithSameNonce( - listOfTxParams: TransactionParams[] = [], + listOfTxParams: (TransactionParams & { chainId: Hex })[] = [], { hasNonce }: { hasNonce?: boolean } = {}, ): Promise { log('Approving transactions with same nonce', { @@ -1550,12 +1660,26 @@ export class TransactionController extends BaseControllerV1< } const initialTx = listOfTxParams[0]; - const common = this.getCommonConfiguration(); + const common = this.getCommonConfiguration(initialTx.chainId); + + // We need to ensure we get the nonce using the the NonceTracker on the chain matching + // the txParams. In this context we only have chainId available to us, but the + // NonceTrackers are keyed by networkClientId. To workaround this, we attempt to find + // a networkClientId that matches the chainId. As a fallback, the globally selected + // network's NonceTracker will be used instead. + let networkClientId: NetworkClientId | undefined; + try { + networkClientId = this.messagingSystem.call( + `NetworkController:findNetworkClientIdByChainId`, + initialTx.chainId, + ); + } catch (err) { + log('failed to find networkClientId from chainId', err); + } const initialTxAsEthTx = TransactionFactory.fromTxData(initialTx, { common, }); - const initialTxAsSerializedHex = bufferToHex(initialTxAsEthTx.serialize()); if (this.inProcessOfSigning.has(initialTxAsSerializedHex)) { @@ -1571,11 +1695,11 @@ export class TransactionController extends BaseControllerV1< const requiresNonce = hasNonce !== true; nonceLock = requiresNonce - ? await this.nonceTracker.getNonceLock(fromAddress) + ? await this.getNonceLock(fromAddress, networkClientId) : undefined; const nonce = nonceLock - ? addHexPrefix(nonceLock.nextNonce.toString(16)) + ? add0x(nonceLock.nextNonce.toString(16)) : initialTx.nonce; if (nonceLock) { @@ -1585,7 +1709,7 @@ export class TransactionController extends BaseControllerV1< rawTransactions = await Promise.all( listOfTxParams.map((txParams) => { txParams.nonce = nonce; - return this.signExternalTransaction(txParams); + return this.signExternalTransaction(txParams.chainId, txParams); }), ); } catch (err) { @@ -1801,6 +1925,7 @@ export class TransactionController extends BaseControllerV1< } private async signExternalTransaction( + chainId: Hex, transactionParams: TransactionParams, ): Promise { if (!this.sign) { @@ -1808,7 +1933,6 @@ export class TransactionController extends BaseControllerV1< } const normalizedTransactionParams = normalizeTxParams(transactionParams); - const chainId = this.getChainId(); const type = isEIP1559Transaction(normalizedTransactionParams) ? TransactionEnvelopeType.feeMarket : TransactionEnvelopeType.legacy; @@ -1820,7 +1944,7 @@ export class TransactionController extends BaseControllerV1< }; const { from } = updatedTransactionParams; - const common = this.getCommonConfiguration(); + const common = this.getCommonConfiguration(chainId); const unsignedTransaction = TransactionFactory.fromTxData( updatedTransactionParams, { common }, @@ -1874,45 +1998,53 @@ export class TransactionController extends BaseControllerV1< private async updateGasProperties(transactionMeta: TransactionMeta) { const isEIP1559Compatible = - (await this.getEIP1559Compatibility()) && + (await this.getEIP1559Compatibility(transactionMeta.networkClientId)) && transactionMeta.txParams.type !== TransactionEnvelopeType.legacy; - const chainId = this.getChainId(); + const { networkClientId, chainId } = transactionMeta; + + const isCustomNetwork = networkClientId + ? this.messagingSystem.call( + `NetworkController:getNetworkClientById`, + networkClientId, + ).configuration.type === NetworkClientType.Custom + : this.getNetworkState().providerConfig.type === NetworkType.rpc; await updateGas({ - ethQuery: this.ethQuery, - providerConfig: this.getNetworkState().providerConfig, + ethQuery: this.#multichainTrackingHelper.getEthQuery({ + networkClientId, + chainId, + }), + chainId, + isCustomNetwork, txMeta: transactionMeta, }); await updateGasFees({ eip1559: isEIP1559Compatible, - ethQuery: this.ethQuery, - getSavedGasFees: this.getSavedGasFees.bind(this, chainId), - getGasFeeEstimates: this.getGasFeeEstimates.bind(this), + ethQuery: this.#multichainTrackingHelper.getEthQuery({ + networkClientId, + chainId, + }), + gasFeeFlows: this.gasFeeFlows, + getGasFeeEstimates: this.getGasFeeEstimates, + getSavedGasFees: this.getSavedGasFees.bind(this), txMeta: transactionMeta, }); } - private getCurrentChainTransactionsByStatus(status: TransactionStatus) { - const chainId = this.getChainId(); - return this.state.transactions.filter( - (transaction) => - transaction.status === status && transaction.chainId === chainId, - ); - } - private onBootCleanup() { this.submitApprovedTransactions(); } /** - * Force to submit approved transactions on current chain. + * Force submit approved transactions for all chains. */ private submitApprovedTransactions() { - const approvedTransactions = this.getCurrentChainTransactionsByStatus( - TransactionStatus.approved, + const approvedTransactions = this.state.transactions.filter( + (transaction) => transaction.status === TransactionStatus.approved, ); + for (const transactionMeta of approvedTransactions) { if (this.beforeApproveOnInit(transactionMeta)) { this.approveTransaction(transactionMeta.id).catch((error) => { @@ -2049,12 +2181,11 @@ export class TransactionController extends BaseControllerV1< private async approveTransaction(transactionId: string) { const { transactions } = this.state; const releaseLock = await this.mutex.acquire(); - const chainId = this.getChainId(); const index = transactions.findIndex(({ id }) => transactionId === id); const transactionMeta = transactions[index]; - const { txParams: { from }, + networkClientId, } = transactionMeta; let releaseNonceLock: (() => void) | undefined; @@ -2067,7 +2198,7 @@ export class TransactionController extends BaseControllerV1< new Error('No sign method defined.'), ); return; - } else if (!chainId) { + } else if (!transactionMeta.chainId) { releaseLock(); this.failTransaction(transactionMeta, new Error('No chainId defined.')); return; @@ -2080,14 +2211,15 @@ export class TransactionController extends BaseControllerV1< const [nonce, releaseNonce] = await getNextNonce( transactionMeta, - this.nonceTracker, + (address: string) => + this.#multichainTrackingHelper.getNonceLock(address, networkClientId), ); releaseNonceLock = releaseNonce; transactionMeta.status = TransactionStatus.approved; transactionMeta.txParams.nonce = nonce; - transactionMeta.txParams.chainId = chainId; + transactionMeta.txParams.chainId = transactionMeta.chainId; const baseTxParams = { ...transactionMeta.txParams, @@ -2123,10 +2255,15 @@ export class TransactionController extends BaseControllerV1< return; } + const ethQuery = this.#multichainTrackingHelper.getEthQuery({ + networkClientId: transactionMeta.networkClientId, + chainId: transactionMeta.chainId, + }); + if (transactionMeta.type === TransactionType.swap) { log('Determining pre-transaction balance'); - const preTxBalance = await query(this.ethQuery, 'getBalance', [from]); + const preTxBalance = await query(ethQuery, 'getBalance', [from]); transactionMeta.preTxBalance = preTxBalance; @@ -2141,7 +2278,7 @@ export class TransactionController extends BaseControllerV1< ); if (hash === undefined) { - hash = await this.publishTransaction(rawTx); + hash = await this.publishTransaction(ethQuery, rawTx); } log('Publish successful', hash); @@ -2174,8 +2311,11 @@ export class TransactionController extends BaseControllerV1< } } - private async publishTransaction(rawTransaction: string): Promise { - return await query(this.ethQuery, 'sendRawTransaction', [rawTransaction]); + private async publishTransaction( + ethQuery: EthQuery, + rawTransaction: string, + ): Promise { + return await query(ethQuery, 'sendRawTransaction', [rawTransaction]); } /** @@ -2327,15 +2467,24 @@ export class TransactionController extends BaseControllerV1< return { meta: transaction, isCompleted }; } - private getChainId(): Hex { + private getChainId(networkClientId?: NetworkClientId): Hex { + if (networkClientId) { + return this.messagingSystem.call( + `NetworkController:getNetworkClientById`, + networkClientId, + ).configuration.chainId; + } const { providerConfig } = this.getNetworkState(); return providerConfig.chainId; } - private prepareUnsignedEthTx(txParams: TransactionParams): TypedTransaction { + private prepareUnsignedEthTx( + chainId: Hex, + txParams: TransactionParams, + ): TypedTransaction { return TransactionFactory.fromTxData(txParams, { - common: this.getCommonConfiguration(), freeze: false, + common: this.getCommonConfiguration(chainId), }); } @@ -2346,23 +2495,11 @@ export class TransactionController extends BaseControllerV1< * specified in txParams, @ethereumjs/tx is able to determine which EIP-2718 * transaction type to use. * + * @param chainId - The chainId to use for the configuration. * @returns common configuration object */ - private getCommonConfiguration(): Common { - const { - providerConfig: { type: chain, chainId, nickname: name }, - } = this.getNetworkState(); - - if ( - chain !== RPC && - chain !== NetworkType['linea-goerli'] && - chain !== NetworkType['linea-mainnet'] - ) { - return new Common({ chain, hardfork: HARDFORK }); - } - + private getCommonConfiguration(chainId: Hex): Common { const customChainParams: Partial = { - name, chainId: parseInt(chainId, 16), defaultHardfork: HARDFORK, }; @@ -2452,7 +2589,7 @@ export class TransactionController extends BaseControllerV1< * @param transactionMeta - Nominated external transaction to be added to state. */ private addExternalTransaction(transactionMeta: TransactionMeta) { - const chainId = this.getChainId(); + const { chainId } = transactionMeta; const { transactions } = this.state; const fromAddress = transactionMeta?.txParams?.from; const sameFromAndNetworkTransactions = transactions.filter( @@ -2493,10 +2630,13 @@ export class TransactionController extends BaseControllerV1< * @param transactionId - Used to identify original transaction. */ private markNonceDuplicatesDropped(transactionId: string) { - const chainId = this.getChainId(); const transactionMeta = this.getTransaction(transactionId); - const nonce = transactionMeta?.txParams?.nonce; - const from = transactionMeta?.txParams?.from; + if (!transactionMeta) { + return; + } + const nonce = transactionMeta.txParams?.nonce; + const from = transactionMeta.txParams?.from; + const { chainId } = transactionMeta; const sameNonceTxs = this.state.transactions.filter( (transaction) => @@ -2513,8 +2653,8 @@ export class TransactionController extends BaseControllerV1< // Mark all same nonce transactions as dropped and give it a replacedBy hash for (const transaction of sameNonceTxs) { - transaction.replacedBy = transactionMeta?.hash; - transaction.replacedById = transactionMeta?.id; + transaction.replacedBy = transactionMeta.hash; + transaction.replacedById = transactionMeta.id; // Drop any transaction that wasn't previously failed (off chain failure) if (transaction.status !== TransactionStatus.failed) { this.setTransactionStatusDropped(transaction); @@ -2579,13 +2719,13 @@ export class TransactionController extends BaseControllerV1< continue; } - transactionMeta[key] = addHexPrefix(value.toString(16)); + transactionMeta[key] = add0x(value.toString(16)); } } - private async getEIP1559Compatibility() { + private async getEIP1559Compatibility(networkClientId?: NetworkClientId) { const currentNetworkIsEIP1559Compatible = - await this.getCurrentNetworkEIP1559Compatibility(); + await this.getCurrentNetworkEIP1559Compatibility(networkClientId); const currentAccountIsEIP1559Compatible = await this.getCurrentAccountEIP1559Compatibility(); @@ -2595,35 +2735,16 @@ export class TransactionController extends BaseControllerV1< ); } - private addPendingTransactionTrackerListeners() { - this.pendingTransactionTracker.hub.on( - 'transaction-confirmed', - this.onConfirmedTransaction.bind(this), - ); - - this.pendingTransactionTracker.hub.on( - 'transaction-dropped', - this.setTransactionStatusDropped.bind(this), - ); - - this.pendingTransactionTracker.hub.on( - 'transaction-failed', - this.failTransaction.bind(this), - ); - - this.pendingTransactionTracker.hub.on( - 'transaction-updated', - this.updateTransaction.bind(this), - ); - } - private async signTransaction( transactionMeta: TransactionMeta, txParams: TransactionParams, ): Promise { log('Signing transaction', txParams); - const unsignedEthTx = this.prepareUnsignedEthTx(txParams); + const unsignedEthTx = this.prepareUnsignedEthTx( + transactionMeta.chainId, + txParams, + ); this.inProcessOfSigning.add(transactionMeta.id); @@ -2684,26 +2805,13 @@ export class TransactionController extends BaseControllerV1< this.hub.emit('transaction-status-update', { transactionMeta }); } - private getNonceTrackerPendingTransactions(address: string) { - const standardPendingTransactions = this.getNonceTrackerTransactions( - TransactionStatus.submitted, - address, - ); - - const externalPendingTransactions = - this.getExternalPendingTransactions(address); - - return [...standardPendingTransactions, ...externalPendingTransactions]; - } - private getNonceTrackerTransactions( status: TransactionStatus, address: string, + chainId: string = this.getChainId(), ) { - const currentChainId = this.getChainId(); - return getAndFormatTransactionsForNonceTracker( - currentChainId, + chainId, address, status, this.state.transactions, @@ -2731,9 +2839,13 @@ export class TransactionController extends BaseControllerV1< return; } + const ethQuery = this.#multichainTrackingHelper.getEthQuery({ + networkClientId: transactionMeta.networkClientId, + chainId: transactionMeta.chainId, + }); const { updatedTransactionMeta, approvalTransactionMeta } = await updatePostTransactionBalance(transactionMeta, { - ethQuery: this.ethQuery, + ethQuery, getTransaction: this.getTransaction.bind(this), updateTransaction: this.updateTransaction.bind(this), }); @@ -2747,4 +2859,241 @@ export class TransactionController extends BaseControllerV1< log('Error while updating post transaction balance', error); } } + + #createNonceTracker({ + provider, + blockTracker, + chainId, + }: { + provider: Provider; + blockTracker: BlockTracker; + chainId?: Hex; + }): NonceTracker { + return new NonceTracker({ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider: provider as any, + blockTracker, + getPendingTransactions: this.#getNonceTrackerPendingTransactions.bind( + this, + chainId, + ), + getConfirmedTransactions: this.getNonceTrackerTransactions.bind( + this, + TransactionStatus.confirmed, + ), + }); + } + + #createIncomingTransactionHelper({ + blockTracker, + etherscanRemoteTransactionSource, + chainId, + }: { + blockTracker: BlockTracker; + etherscanRemoteTransactionSource: EtherscanRemoteTransactionSource; + chainId?: Hex; + }): IncomingTransactionHelper { + const incomingTransactionHelper = new IncomingTransactionHelper({ + blockTracker, + getCurrentAccount: this.getSelectedAddress, + getLastFetchedBlockNumbers: () => this.state.lastFetchedBlockNumbers, + getChainId: chainId ? () => chainId : this.getChainId.bind(this), + isEnabled: this.#incomingTransactionOptions.isEnabled, + queryEntireHistory: this.#incomingTransactionOptions.queryEntireHistory, + remoteTransactionSource: etherscanRemoteTransactionSource, + transactionLimit: this.config.txHistoryLimit, + updateTransactions: this.#incomingTransactionOptions.updateTransactions, + }); + + this.#addIncomingTransactionHelperListeners(incomingTransactionHelper); + + return incomingTransactionHelper; + } + + #createPendingTransactionTracker({ + provider, + blockTracker, + chainId, + }: { + provider: Provider; + blockTracker: BlockTracker; + chainId?: Hex; + }): PendingTransactionTracker { + const ethQuery = new EthQuery(provider); + const getChainId = chainId ? () => chainId : this.getChainId.bind(this); + + const pendingTransactionTracker = new PendingTransactionTracker({ + approveTransaction: this.approveTransaction.bind(this), + blockTracker, + getChainId, + getEthQuery: () => ethQuery, + getTransactions: () => this.state.transactions, + isResubmitEnabled: this.#pendingTransactionOptions.isResubmitEnabled, + getGlobalLock: () => + this.#multichainTrackingHelper.acquireNonceLockForChainIdKey({ + chainId: getChainId(), + }), + publishTransaction: this.publishTransaction.bind(this), + hooks: { + beforeCheckPendingTransaction: + this.beforeCheckPendingTransaction.bind(this), + beforePublish: this.beforePublish.bind(this), + }, + }); + + this.#addPendingTransactionTrackerListeners(pendingTransactionTracker); + + return pendingTransactionTracker; + } + + #checkForPendingTransactionAndStartPolling = () => { + // PendingTransactionTracker reads state through its getTransactions hook + this.pendingTransactionTracker.startIfPendingTransactions(); + this.#multichainTrackingHelper.checkForPendingTransactionAndStartPolling(); + }; + + #stopAllTracking() { + this.pendingTransactionTracker.stop(); + this.#removePendingTransactionTrackerListeners( + this.pendingTransactionTracker, + ); + this.incomingTransactionHelper.stop(); + this.#removeIncomingTransactionHelperListeners( + this.incomingTransactionHelper, + ); + + this.#multichainTrackingHelper.stopAllTracking(); + } + + #removeIncomingTransactionHelperListeners( + incomingTransactionHelper: IncomingTransactionHelper, + ) { + incomingTransactionHelper.hub.removeAllListeners('transactions'); + incomingTransactionHelper.hub.removeAllListeners( + 'updatedLastFetchedBlockNumbers', + ); + } + + #addIncomingTransactionHelperListeners( + incomingTransactionHelper: IncomingTransactionHelper, + ) { + incomingTransactionHelper.hub.on( + 'transactions', + this.onIncomingTransactions.bind(this), + ); + incomingTransactionHelper.hub.on( + 'updatedLastFetchedBlockNumbers', + this.onUpdatedLastFetchedBlockNumbers.bind(this), + ); + } + + #removePendingTransactionTrackerListeners( + pendingTransactionTracker: PendingTransactionTracker, + ) { + pendingTransactionTracker.hub.removeAllListeners('transaction-confirmed'); + pendingTransactionTracker.hub.removeAllListeners('transaction-dropped'); + pendingTransactionTracker.hub.removeAllListeners('transaction-failed'); + pendingTransactionTracker.hub.removeAllListeners('transaction-updated'); + } + + #addPendingTransactionTrackerListeners( + pendingTransactionTracker: PendingTransactionTracker, + ) { + pendingTransactionTracker.hub.on( + 'transaction-confirmed', + this.onConfirmedTransaction.bind(this), + ); + + pendingTransactionTracker.hub.on( + 'transaction-dropped', + this.setTransactionStatusDropped.bind(this), + ); + + pendingTransactionTracker.hub.on( + 'transaction-failed', + this.failTransaction.bind(this), + ); + + pendingTransactionTracker.hub.on( + 'transaction-updated', + this.updateTransaction.bind(this), + ); + } + + #getNonceTrackerPendingTransactions( + chainId: string | undefined, + address: string, + ) { + const standardPendingTransactions = this.getNonceTrackerTransactions( + TransactionStatus.submitted, + address, + chainId, + ); + + const externalPendingTransactions = this.getExternalPendingTransactions( + address, + chainId, + ); + return [...standardPendingTransactions, ...externalPendingTransactions]; + } + + private async publishTransactionForRetry( + ethQuery: EthQuery, + rawTx: string, + transactionMeta: TransactionMeta, + ): Promise { + try { + const hash = await this.publishTransaction(ethQuery, rawTx); + return hash; + } catch (error: unknown) { + if (this.isTransactionAlreadyConfirmedError(error as Error)) { + await this.pendingTransactionTracker.forceCheckTransaction( + transactionMeta, + ); + throw new Error('Previous transaction is already confirmed'); + } + throw error; + } + } + + /** + * Ensures that error is a nonce issue + * + * @param error - The error to check + * @returns Whether or not the error is a nonce issue + */ + // TODO: Replace `any` with type + // Some networks are returning original error in the data field + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private isTransactionAlreadyConfirmedError(error: any): boolean { + return ( + error?.message?.includes('nonce too low') || + error?.data?.message?.includes('nonce too low') + ); + } + + #getGasFeeFlows(): GasFeeFlow[] { + return [new LineaGasFeeFlow(), new DefaultGasFeeFlow()]; + } + + #updateTransactionInternal( + transactionMeta: TransactionMeta, + { note, skipHistory }: { note?: string; skipHistory?: boolean }, + ) { + const { transactions } = this.state; + + transactionMeta.txParams = normalizeTxParams(transactionMeta.txParams); + + validateTxParams(transactionMeta.txParams); + + if (skipHistory !== true) { + updateTransactionHistory(transactionMeta, note ?? 'Transaction updated'); + } + + const index = transactions.findIndex(({ id }) => transactionMeta.id === id); + transactions[index] = transactionMeta; + + this.update({ transactions: this.trimTransactionsForState(transactions) }); + } } diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts new file mode 100644 index 0000000000..c54f5adaf4 --- /dev/null +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -0,0 +1,1936 @@ +import { ApprovalController } from '@metamask/approval-controller'; +import { ControllerMessenger } from '@metamask/base-controller'; +import { + ApprovalType, + BUILT_IN_NETWORKS, + InfuraNetworkType, + NetworkType, +} from '@metamask/controller-utils'; +import { + NetworkController, + NetworkClientType, +} from '@metamask/network-controller'; +import type { NetworkClientConfiguration } from '@metamask/network-controller'; +import nock from 'nock'; +import type { SinonFakeTimers } from 'sinon'; +import { useFakeTimers } from 'sinon'; + +import { advanceTime } from '../../../tests/helpers'; +import { mockNetwork } from '../../../tests/mock-network'; +import { + ETHERSCAN_TRANSACTION_BASE_MOCK, + ETHERSCAN_TRANSACTION_RESPONSE_MOCK, + ETHERSCAN_TOKEN_TRANSACTION_MOCK, + ETHERSCAN_TRANSACTION_SUCCESS_MOCK, +} from '../tests/EtherscanMocks'; +import { + buildEthGasPriceRequestMock, + buildEthBlockNumberRequestMock, + buildEthGetCodeRequestMock, + buildEthGetBlockByNumberRequestMock, + buildEthEstimateGasRequestMock, + buildEthGetTransactionCountRequestMock, + buildEthGetBlockByHashRequestMock, + buildEthSendRawTransactionRequestMock, + buildEthGetTransactionReceiptRequestMock, +} from '../tests/JsonRpcRequestMocks'; +import { TransactionController } from './TransactionController'; +import type { TransactionMeta } from './types'; +import { TransactionStatus, TransactionType } from './types'; +import { getEtherscanApiHost } from './utils/etherscan'; +import * as etherscanUtils from './utils/etherscan'; + +const ACCOUNT_MOCK = '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207'; +const ACCOUNT_2_MOCK = '0x08f137f335ea1b8f193b8f6ea92561a60d23a211'; +const ACCOUNT_3_MOCK = '0xe688b84b23f322a994a53dbf8e15fa82cdb71127'; +const infuraProjectId = 'fake-infura-project-id'; + +const BLOCK_TRACKER_POLLING_INTERVAL = 20000; + +/** + * Builds the Infura network client configuration. + * @param network - The Infura network type. + * @returns The network client configuration. + */ +function buildInfuraNetworkClientConfiguration( + network: InfuraNetworkType, +): NetworkClientConfiguration { + return { + type: NetworkClientType.Infura, + network, + chainId: BUILT_IN_NETWORKS[network].chainId, + infuraProjectId, + ticker: BUILT_IN_NETWORKS[network].ticker, + }; +} + +const customGoerliNetworkClientConfiguration = { + type: NetworkClientType.Custom, + chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, + ticker: BUILT_IN_NETWORKS[NetworkType.goerli].ticker, + rpcUrl: 'https://mock.rpc.url', +} as const; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const newController = async (options: any = {}) => { + // Mainnet network must be mocked for NetworkController instantiation + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + mocks: [ + { + ...buildEthBlockNumberRequestMock('0x1'), + discardAfterMatching: false, + }, + ], + }); + + const messenger = new ControllerMessenger(); + const networkController = new NetworkController({ + messenger: messenger.getRestricted({ name: 'NetworkController' }), + trackMetaMetricsEvent: () => { + // noop + }, + infuraProjectId, + }); + await networkController.initializeProvider(); + const { provider, blockTracker } = + networkController.getProviderAndBlockTracker(); + + const approvalController = new ApprovalController({ + messenger: messenger.getRestricted({ + name: 'ApprovalController', + }), + showApprovalRequest: jest.fn(), + typesExcludedFromRateLimiting: [ApprovalType.Transaction], + }); + + const { state, config, ...opts } = options; + + const transactionController = new TransactionController( + { + provider, + blockTracker, + messenger, + onNetworkStateChange: () => { + // noop + }, + getCurrentNetworkEIP1559Compatibility: + networkController.getEIP1559Compatibility.bind(networkController), + getNetworkClientRegistry: + opts.getNetworkClientRegistrySpy || + networkController.getNetworkClientRegistry.bind(networkController), + findNetworkClientIdByChainId: + networkController.findNetworkClientIdByChainId.bind(networkController), + getNetworkClientById: + networkController.getNetworkClientById.bind(networkController), + getNetworkState: () => networkController.state, + getSelectedAddress: () => '0xdeadbeef', + getPermittedAccounts: () => [ACCOUNT_MOCK], + isMultichainEnabled: true, + ...opts, + }, + { + sign: (transaction) => Promise.resolve(transaction), + ...config, + }, + state, + ); + + return { + transactionController, + approvalController, + networkController, + }; +}; + +describe('TransactionController Integration', () => { + let clock: SinonFakeTimers; + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('constructor', () => { + it('should create a new instance of TransactionController', async () => { + const { transactionController } = await newController({}); + expect(transactionController).toBeDefined(); + transactionController.destroy(); + }); + + it('should submit all approved transactions in state', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthSendRawTransactionRequestMock( + '0x02e2050101018252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + ], + }); + + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.sepolia, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthSendRawTransactionRequestMock( + '0x02e583aa36a70101018252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + ], + }); + + const { transactionController } = await newController({ + state: { + transactions: [ + { + actionId: undefined, + chainId: '0x5', + dappSuggestedGasFees: undefined, + deviceConfirmedOn: undefined, + id: 'ecfe8c60-ba27-11ee-8643-dfd28279a442', + origin: undefined, + securityAlertResponse: undefined, + status: 'approved', + time: 1706039113766, + txParams: { + from: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207', + gas: '0x5208', + nonce: '0x1', + to: '0x08f137f335ea1b8f193b8f6ea92561a60d23a211', + value: '0x0', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + }, + userEditedGasLimit: false, + verifiedOnBlockchain: false, + type: 'simpleSend', + networkClientId: 'goerli', + simulationFails: undefined, + originalGasEstimate: '0x5208', + defaultGasEstimates: { + gas: '0x5208', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + gasPrice: undefined, + estimateType: 'dappSuggested', + }, + userFeeLevel: 'dappSuggested', + sendFlowHistory: [], + history: [{}, []], + }, + { + actionId: undefined, + chainId: '0xaa36a7', + dappSuggestedGasFees: undefined, + deviceConfirmedOn: undefined, + id: 'c4cc0ff0-ba28-11ee-926f-55a7f9c2c2c6', + origin: undefined, + securityAlertResponse: undefined, + status: 'approved', + time: 1706039113766, + txParams: { + from: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207', + gas: '0x5208', + nonce: '0x1', + to: '0x08f137f335ea1b8f193b8f6ea92561a60d23a211', + value: '0x0', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + }, + userEditedGasLimit: false, + verifiedOnBlockchain: false, + type: 'simpleSend', + networkClientId: 'sepolia', + simulationFails: undefined, + originalGasEstimate: '0x5208', + defaultGasEstimates: { + gas: '0x5208', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + gasPrice: undefined, + estimateType: 'dappSuggested', + }, + userFeeLevel: 'dappSuggested', + sendFlowHistory: [], + history: [{}, []], + }, + ], + }, + }); + await advanceTime({ clock, duration: 1 }); + + expect(transactionController.state.transactions).toHaveLength(2); + expect(transactionController.state.transactions[0].status).toBe( + 'submitted', + ); + expect(transactionController.state.transactions[1].status).toBe( + 'submitted', + ); + transactionController.destroy(); + }); + }); + describe('multichain transaction lifecycle', () => { + describe('when a transaction is added with a networkClientId that does not match the globally selected network', () => { + it('should add a new unapproved transaction', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + ], + }); + const { transactionController } = await newController({}); + await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: 'goerli' }, + ); + expect(transactionController.state.transactions).toHaveLength(1); + expect(transactionController.state.transactions[0].status).toBe( + 'unapproved', + ); + transactionController.destroy(); + }); + it('should be able to get to submitted state', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthEstimateGasRequestMock(ACCOUNT_MOCK, ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK), + buildEthSendRawTransactionRequestMock( + '0x02e2050101018252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + ], + }); + const { transactionController, approvalController } = + await newController({}); + const { result, transactionMeta } = + await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: 'goerli' }, + ); + + await approvalController.accept(transactionMeta.id); + await advanceTime({ clock, duration: 1 }); + + await result; + + expect(transactionController.state.transactions).toHaveLength(1); + expect(transactionController.state.transactions[0].status).toBe( + 'submitted', + ); + transactionController.destroy(); + }); + it('should be able to get to confirmed state', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthEstimateGasRequestMock(ACCOUNT_MOCK, ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK), + buildEthSendRawTransactionRequestMock( + '0x02e2050101018252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + buildEthBlockNumberRequestMock('0x3'), + buildEthGetTransactionReceiptRequestMock('0x1', '0x1', '0x3'), + buildEthGetBlockByHashRequestMock('0x1'), + ], + }); + const { transactionController, approvalController } = + await newController({}); + const { result, transactionMeta } = + await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: 'goerli' }, + ); + + await approvalController.accept(transactionMeta.id); + await advanceTime({ clock, duration: 1 }); + + await result; + // blocktracker polling is 20s + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await advanceTime({ clock, duration: 1 }); + await advanceTime({ clock, duration: 1 }); + + expect(transactionController.state.transactions).toHaveLength(1); + expect(transactionController.state.transactions[0].status).toBe( + 'confirmed', + ); + transactionController.destroy(); + }); + it('should be able to send and confirm transactions on different chains', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthEstimateGasRequestMock(ACCOUNT_MOCK, ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK), + buildEthSendRawTransactionRequestMock( + '0x02e2050101018252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + buildEthBlockNumberRequestMock('0x3'), + buildEthGetTransactionReceiptRequestMock('0x1', '0x1', '0x3'), + buildEthGetBlockByHashRequestMock('0x1'), + ], + }); + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.sepolia, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthEstimateGasRequestMock(ACCOUNT_MOCK, ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK), + buildEthSendRawTransactionRequestMock( + '0x02e583aa36a70101018252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + buildEthBlockNumberRequestMock('0x3'), + buildEthGetTransactionReceiptRequestMock('0x1', '0x1', '0x3'), + buildEthGetBlockByHashRequestMock('0x1'), + ], + }); + const { transactionController, approvalController } = + await newController({}); + const firstTransaction = await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: 'goerli' }, + ); + const secondTransaction = await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: 'sepolia', origin: 'test' }, + ); + + await Promise.all([ + approvalController.accept(firstTransaction.transactionMeta.id), + approvalController.accept(secondTransaction.transactionMeta.id), + ]); + await advanceTime({ clock, duration: 1 }); + await advanceTime({ clock, duration: 1 }); + + await Promise.all([firstTransaction.result, secondTransaction.result]); + + // blocktracker polling is 20s + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await advanceTime({ clock, duration: 1 }); + await advanceTime({ clock, duration: 1 }); + + expect(transactionController.state.transactions).toHaveLength(2); + expect(transactionController.state.transactions[0].status).toBe( + 'confirmed', + ); + expect( + transactionController.state.transactions[0].networkClientId, + ).toBe('sepolia'); + expect(transactionController.state.transactions[1].status).toBe( + 'confirmed', + ); + expect( + transactionController.state.transactions[1].networkClientId, + ).toBe('goerli'); + transactionController.destroy(); + }); + it('should be able to cancel a transaction', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthEstimateGasRequestMock(ACCOUNT_MOCK, ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK), + buildEthSendRawTransactionRequestMock( + '0x02e2050101018252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + buildEthBlockNumberRequestMock('0x3'), + buildEthGetTransactionReceiptRequestMock('0x1', '0x1', '0x3'), + buildEthGetBlockByHashRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x3'), + buildEthSendRawTransactionRequestMock( + '0x02e205010101825208946bf137f335ea1b8f193b8f6ea92561a60d23a2078080c0808080', + '0x2', + ), + buildEthGetTransactionReceiptRequestMock('0x2', '0x1', '0x3'), + ], + }); + const { transactionController, approvalController } = + await newController({}); + const { result, transactionMeta } = + await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: 'goerli' }, + ); + + await approvalController.accept(transactionMeta.id); + await advanceTime({ clock, duration: 1 }); + + await result; + + await transactionController.stopTransaction(transactionMeta.id); + + expect(transactionController.state.transactions).toHaveLength(2); + expect(transactionController.state.transactions[1].status).toBe( + 'submitted', + ); + transactionController.destroy(); + }); + it('should be able to confirm a cancelled transaction and drop the original transaction', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthEstimateGasRequestMock(ACCOUNT_MOCK, ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK), + buildEthSendRawTransactionRequestMock( + '0x02e2050101018252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + buildEthBlockNumberRequestMock('0x3'), + buildEthSendRawTransactionRequestMock( + '0x02e205010101825208946bf137f335ea1b8f193b8f6ea92561a60d23a2078080c0808080', + '0x2', + ), + { + ...buildEthGetTransactionReceiptRequestMock('0x1', '0x0', '0x0'), + response: { result: null }, + }, + buildEthBlockNumberRequestMock('0x4'), + buildEthBlockNumberRequestMock('0x4'), + { + ...buildEthGetTransactionReceiptRequestMock('0x1', '0x0', '0x0'), + response: { result: null }, + }, + buildEthGetTransactionReceiptRequestMock('0x2', '0x2', '0x4'), + buildEthGetBlockByHashRequestMock('0x2'), + ], + }); + const { transactionController, approvalController } = + await newController({}); + const { result, transactionMeta } = + await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: 'goerli' }, + ); + + await approvalController.accept(transactionMeta.id); + await advanceTime({ clock, duration: 1 }); + + await result; + + await transactionController.stopTransaction(transactionMeta.id); + + // blocktracker polling is 20s + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await advanceTime({ clock, duration: 1 }); + await advanceTime({ clock, duration: 1 }); + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await advanceTime({ clock, duration: 1 }); + await advanceTime({ clock, duration: 1 }); + + expect(transactionController.state.transactions).toHaveLength(2); + expect(transactionController.state.transactions[0].status).toBe( + 'dropped', + ); + expect(transactionController.state.transactions[1].status).toBe( + 'confirmed', + ); + transactionController.destroy(); + }); + it('should be able to get to speedup state and drop the original transaction', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthEstimateGasRequestMock(ACCOUNT_MOCK, ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK), + buildEthSendRawTransactionRequestMock( + '0x02e605018203e88203e88252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + buildEthBlockNumberRequestMock('0x3'), + { + ...buildEthGetTransactionReceiptRequestMock('0x1', '0x0', '0x0'), + response: { result: null }, + }, + buildEthSendRawTransactionRequestMock( + '0x02e6050182044c82044c8252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x2', + ), + buildEthBlockNumberRequestMock('0x4'), + buildEthBlockNumberRequestMock('0x4'), + { + ...buildEthGetTransactionReceiptRequestMock('0x1', '0x0', '0x0'), + response: { result: null }, + }, + buildEthGetTransactionReceiptRequestMock('0x2', '0x2', '0x4'), + buildEthGetBlockByHashRequestMock('0x2'), + ], + }); + const { transactionController, approvalController } = + await newController({}); + const { result, transactionMeta } = + await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + maxFeePerGas: '0x3e8', + }, + { networkClientId: 'goerli' }, + ); + + await approvalController.accept(transactionMeta.id); + await advanceTime({ clock, duration: 1 }); + + await result; + + await transactionController.speedUpTransaction(transactionMeta.id); + + // blocktracker polling is 20s + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await advanceTime({ clock, duration: 1 }); + await advanceTime({ clock, duration: 1 }); + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await advanceTime({ clock, duration: 1 }); + await advanceTime({ clock, duration: 1 }); + + expect(transactionController.state.transactions).toHaveLength(2); + expect(transactionController.state.transactions[0].status).toBe( + 'dropped', + ); + expect(transactionController.state.transactions[1].status).toBe( + 'confirmed', + ); + const baseFee = + transactionController.state.transactions[0].txParams.maxFeePerGas; + expect( + Number( + transactionController.state.transactions[1].txParams.maxFeePerGas, + ), + ).toBeGreaterThan(Number(baseFee)); + transactionController.destroy(); + }); + }); + + describe('when transactions are added concurrently with different networkClientIds but on the same chainId', () => { + it('should add each transaction with consecutive nonces', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthEstimateGasRequestMock(ACCOUNT_MOCK, ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK), + buildEthSendRawTransactionRequestMock( + '0x02e2050101018252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + buildEthBlockNumberRequestMock('0x3'), + buildEthGetTransactionReceiptRequestMock('0x1', '0x1', '0x3'), + buildEthGetBlockByHashRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x3'), + ], + }); + + mockNetwork({ + networkClientConfiguration: customGoerliNetworkClientConfiguration, + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthEstimateGasRequestMock(ACCOUNT_MOCK, ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK), + buildEthSendRawTransactionRequestMock( + '0x02e0050201018094e688b84b23f322a994a53dbf8e15fa82cdb711278080c0808080', + '0x1', + ), + buildEthBlockNumberRequestMock('0x3'), + buildEthGetTransactionReceiptRequestMock('0x1', '0x1', '0x3'), + buildEthGetBlockByHashRequestMock('0x1'), + ], + }); + + const { approvalController, networkController, transactionController } = + await newController({ + getPermittedAccounts: () => [ACCOUNT_MOCK], + getSelectedAddress: () => ACCOUNT_MOCK, + }); + const otherNetworkClientIdOnGoerli = + await networkController.upsertNetworkConfiguration( + { + rpcUrl: 'https://mock.rpc.url', + chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, + ticker: BUILT_IN_NETWORKS[NetworkType.goerli].ticker, + }, + { + referrer: 'https://mock.referrer', + source: 'dapp', + }, + ); + + const addTx1 = await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: 'goerli' }, + ); + + const addTx2 = await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_3_MOCK, + }, + { + networkClientId: otherNetworkClientIdOnGoerli, + }, + ); + + await Promise.all([ + approvalController.accept(addTx1.transactionMeta.id), + approvalController.accept(addTx2.transactionMeta.id), + ]); + await advanceTime({ clock, duration: 1 }); + + await Promise.all([addTx1.result, addTx2.result]); + + const nonces = transactionController.state.transactions + .map((tx) => tx.txParams.nonce) + .sort(); + expect(nonces).toStrictEqual(['0x1', '0x2']); + transactionController.destroy(); + }); + }); + + describe('when transactions are added concurrently with the same networkClientId', () => { + it('should add each transaction with consecutive nonces', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthGetCodeRequestMock(ACCOUNT_3_MOCK), + buildEthEstimateGasRequestMock(ACCOUNT_MOCK, ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK), + buildEthSendRawTransactionRequestMock( + '0x02e2050101018252089408f137f335ea1b8f193b8f6ea92561a60d23a2118080c0808080', + '0x1', + ), + buildEthBlockNumberRequestMock('0x3'), + buildEthGetTransactionReceiptRequestMock('0x1', '0x1', '0x3'), + buildEthGetBlockByHashRequestMock('0x1'), + buildEthSendRawTransactionRequestMock( + '0x02e20502010182520894e688b84b23f322a994a53dbf8e15fa82cdb711278080c0808080', + '0x2', + ), + buildEthGetTransactionReceiptRequestMock('0x2', '0x2', '0x4'), + ], + }); + const { approvalController, transactionController } = + await newController({ + getPermittedAccounts: () => [ACCOUNT_MOCK], + getSelectedAddress: () => ACCOUNT_MOCK, + }); + + const addTx1 = await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: 'goerli' }, + ); + + await advanceTime({ clock, duration: 1 }); + + const addTx2 = await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_3_MOCK, + }, + { + networkClientId: 'goerli', + }, + ); + + await advanceTime({ clock, duration: 1 }); + + await Promise.all([ + approvalController.accept(addTx1.transactionMeta.id), + approvalController.accept(addTx2.transactionMeta.id), + ]); + + await advanceTime({ clock, duration: 1 }); + + await Promise.all([addTx1.result, addTx2.result]); + + const nonces = transactionController.state.transactions + .map((tx) => tx.txParams.nonce) + .sort(); + expect(nonces).toStrictEqual(['0x1', '0x2']); + transactionController.destroy(); + }); + }); + }); + + describe('when changing rpcUrl of networkClient', () => { + it('should start tracking when a new network is added', async () => { + mockNetwork({ + networkClientConfiguration: customGoerliNetworkClientConfiguration, + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x1'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + buildEthGasPriceRequestMock(), + ], + }); + const { networkController, transactionController } = + await newController(); + + const otherNetworkClientIdOnGoerli = + await networkController.upsertNetworkConfiguration( + customGoerliNetworkClientConfiguration, + { + setActive: false, + referrer: 'https://mock.referrer', + source: 'dapp', + }, + ); + + await transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_3_MOCK, + }, + { + networkClientId: otherNetworkClientIdOnGoerli, + }, + ); + + expect(transactionController.state.transactions[0]).toStrictEqual( + expect.objectContaining({ + networkClientId: otherNetworkClientIdOnGoerli, + }), + ); + transactionController.destroy(); + }); + it('should stop tracking when a network is removed', async () => { + const { networkController, transactionController } = + await newController(); + + const configurationId = + await networkController.upsertNetworkConfiguration( + customGoerliNetworkClientConfiguration, + { + setActive: false, + referrer: 'https://mock.referrer', + source: 'dapp', + }, + ); + + networkController.removeNetworkConfiguration(configurationId); + + await expect( + transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: configurationId }, + ), + ).rejects.toThrow( + 'The networkClientId for this transaction could not be found', + ); + + expect(transactionController).toBeDefined(); + transactionController.destroy(); + }); + }); + + describe('feature flag', () => { + it('should not allow transaction to be added with a networkClientId when feature flag is disabled', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + buildEthGetBlockByNumberRequestMock('0x1'), + buildEthGasPriceRequestMock(), + buildEthGetCodeRequestMock(ACCOUNT_2_MOCK), + ], + }); + + const { networkController, transactionController } = await newController({ + isMultichainEnabled: false, + }); + + const configurationId = + await networkController.upsertNetworkConfiguration( + customGoerliNetworkClientConfiguration, + { + setActive: false, + referrer: 'https://mock.referrer', + source: 'dapp', + }, + ); + + // add a transaction with the networkClientId of the newly added network + // and expect it to throw since the networkClientId won't be found in the trackingMap + await expect( + transactionController.addTransaction( + { + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }, + { networkClientId: configurationId }, + ), + ).rejects.toThrow( + 'The networkClientId for this transaction could not be found', + ); + + // adding a transaction without a networkClientId should work + expect( + await transactionController.addTransaction({ + from: ACCOUNT_MOCK, + to: ACCOUNT_2_MOCK, + }), + ).toBeDefined(); + transactionController.destroy(); + }); + it('should not call getNetworkClientRegistry on networkController:stateChange when feature flag is disabled', async () => { + const getNetworkClientRegistrySpy = jest.fn().mockImplementation(() => { + return { + [NetworkType.goerli]: { + configuration: customGoerliNetworkClientConfiguration, + }, + }; + }); + + const { networkController, transactionController } = await newController({ + isMultichainEnabled: false, + getNetworkClientRegistrySpy, + }); + + await networkController.upsertNetworkConfiguration( + customGoerliNetworkClientConfiguration, + { + setActive: false, + referrer: 'https://mock.referrer', + source: 'dapp', + }, + ); + + expect(getNetworkClientRegistrySpy).not.toHaveBeenCalled(); + transactionController.destroy(); + }); + it('should call getNetworkClientRegistry on networkController:stateChange when feature flag is enabled', async () => { + const getNetworkClientRegistrySpy = jest.fn().mockImplementation(() => { + return { + [NetworkType.goerli]: { + configuration: BUILT_IN_NETWORKS[NetworkType.goerli], + }, + }; + }); + + const { networkController, transactionController } = await newController({ + isMultichainEnabled: true, + getNetworkClientRegistrySpy, + }); + + await networkController.upsertNetworkConfiguration( + customGoerliNetworkClientConfiguration, + { + setActive: false, + referrer: 'https://mock.referrer', + source: 'dapp', + }, + ); + + expect(getNetworkClientRegistrySpy).toHaveBeenCalled(); + transactionController.destroy(); + }); + it('should call getNetworkClientRegistry on construction when feature flag is enabled', async () => { + const getNetworkClientRegistrySpy = jest.fn().mockImplementation(() => { + return { + [NetworkType.goerli]: { + configuration: BUILT_IN_NETWORKS[NetworkType.goerli], + }, + }; + }); + + await newController({ + isMultichainEnabled: true, + getNetworkClientRegistrySpy, + }); + + expect(getNetworkClientRegistrySpy).toHaveBeenCalled(); + }); + }); + + describe('startIncomingTransactionPolling', () => { + // TODO(JL): IncomingTransactionHelper doesn't populate networkClientId on the generated tx object. Should it?.. + it('should add incoming transactions to state with the correct chainId for the given networkClientId on the next block', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + ], + }); + + const selectedAddress = ETHERSCAN_TRANSACTION_BASE_MOCK.to; + + const { networkController, transactionController } = await newController({ + getSelectedAddress: () => selectedAddress, + }); + + const expectedLastFetchedBlockNumbers: Record = {}; + const expectedTransactions: Partial[] = []; + + const networkClients = networkController.getNetworkClientRegistry(); + const networkClientIds = Object.keys(networkClients); + await Promise.all( + networkClientIds.map(async (networkClientId) => { + const config = networkClients[networkClientId].configuration; + mockNetwork({ + networkClientConfiguration: config, + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + ], + }); + nock(getEtherscanApiHost(config.chainId)) + .get( + `/api?module=account&address=${selectedAddress}&offset=40&sort=desc&action=txlist&tag=latest&page=1`, + ) + .reply(200, ETHERSCAN_TRANSACTION_RESPONSE_MOCK); + + transactionController.startIncomingTransactionPolling([ + networkClientId, + ]); + + expectedLastFetchedBlockNumbers[ + `${config.chainId}#${selectedAddress}#normal` + ] = parseInt(ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, 10); + expectedTransactions.push({ + blockNumber: ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, + chainId: config.chainId, + type: TransactionType.incoming, + verifiedOnBlockchain: false, + status: TransactionStatus.confirmed, + }); + expectedTransactions.push({ + blockNumber: ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, + chainId: config.chainId, + type: TransactionType.incoming, + verifiedOnBlockchain: false, + status: TransactionStatus.failed, + }); + }), + ); + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + + expect(transactionController.state.transactions).toHaveLength( + 2 * networkClientIds.length, + ); + expect(transactionController.state.transactions).toStrictEqual( + expect.arrayContaining( + expectedTransactions.map(expect.objectContaining), + ), + ); + expect(transactionController.state.lastFetchedBlockNumbers).toStrictEqual( + expectedLastFetchedBlockNumbers, + ); + transactionController.destroy(); + }); + + it('should start the global incoming transaction helper when no networkClientIds provided', async () => { + const selectedAddress = ETHERSCAN_TRANSACTION_BASE_MOCK.to; + + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + ], + }); + nock(getEtherscanApiHost(BUILT_IN_NETWORKS[NetworkType.mainnet].chainId)) + .get( + `/api?module=account&address=${selectedAddress}&offset=40&sort=desc&action=txlist&tag=latest&page=1`, + ) + .reply(200, ETHERSCAN_TRANSACTION_RESPONSE_MOCK); + + const { transactionController } = await newController({ + getSelectedAddress: () => selectedAddress, + }); + + transactionController.startIncomingTransactionPolling(); + + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + + expect(transactionController.state.transactions).toHaveLength(2); + expect(transactionController.state.transactions).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + blockNumber: ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, + chainId: '0x1', + type: TransactionType.incoming, + verifiedOnBlockchain: false, + status: TransactionStatus.confirmed, + }), + expect.objectContaining({ + blockNumber: ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, + chainId: '0x1', + type: TransactionType.incoming, + verifiedOnBlockchain: false, + status: TransactionStatus.failed, + }), + ]), + ); + expect(transactionController.state.lastFetchedBlockNumbers).toStrictEqual( + { + [`0x1#${selectedAddress}#normal`]: parseInt( + ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, + 10, + ), + }, + ); + transactionController.destroy(); + }); + + describe('when called with multiple networkClients which share the same chainId', () => { + it('should only call the etherscan API max every 5 seconds, alternating between the token and txlist endpoints', async () => { + const fetchEtherscanNativeTxFetchSpy = jest.spyOn( + etherscanUtils, + 'fetchEtherscanTransactions', + ); + + const fetchEtherscanTokenTxFetchSpy = jest.spyOn( + etherscanUtils, + 'fetchEtherscanTokenTransactions', + ); + + // mocking infura mainnet + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + ], + }); + + // mocking infura goerli + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + ], + }); + + // mock the other goerli network client node requests + mockNetwork({ + networkClientConfiguration: { + type: NetworkClientType.Custom, + chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, + ticker: BUILT_IN_NETWORKS[NetworkType.goerli].ticker, + rpcUrl: 'https://mock.rpc.url', + }, + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + buildEthBlockNumberRequestMock('0x3'), + buildEthBlockNumberRequestMock('0x4'), + ], + }); + + const selectedAddress = ETHERSCAN_TRANSACTION_BASE_MOCK.to; + + const { networkController, transactionController } = + await newController({ + getSelectedAddress: () => selectedAddress, + }); + + const otherGoerliClientNetworkClientId = + await networkController.upsertNetworkConfiguration( + customGoerliNetworkClientConfiguration, + { + referrer: 'https://mock.referrer', + source: 'dapp', + }, + ); + + // Etherscan API Mocks + + // Non-token transactions + nock(getEtherscanApiHost(BUILT_IN_NETWORKS[NetworkType.goerli].chainId)) + .get( + `/api?module=account&address=${ETHERSCAN_TRANSACTION_BASE_MOCK.to}&offset=40&sort=desc&action=txlist&tag=latest&page=1`, + ) + .reply(200, { + status: '1', + result: [{ ...ETHERSCAN_TRANSACTION_SUCCESS_MOCK, blockNumber: 1 }], + }) + // block 2 + .get( + `/api?module=account&address=${ETHERSCAN_TRANSACTION_BASE_MOCK.to}&startBlock=2&offset=40&sort=desc&action=txlist&tag=latest&page=1`, + ) + .reply(200, { + status: '1', + result: [{ ...ETHERSCAN_TRANSACTION_SUCCESS_MOCK, blockNumber: 2 }], + }) + .persist(); + + // token transactions + nock(getEtherscanApiHost(BUILT_IN_NETWORKS[NetworkType.goerli].chainId)) + .get( + `/api?module=account&address=${ETHERSCAN_TRANSACTION_BASE_MOCK.to}&offset=40&sort=desc&action=tokentx&tag=latest&page=1`, + ) + .reply(200, { + status: '1', + result: [{ ...ETHERSCAN_TOKEN_TRANSACTION_MOCK, blockNumber: 1 }], + }) + .get( + `/api?module=account&address=${ETHERSCAN_TRANSACTION_BASE_MOCK.to}&startBlock=2&offset=40&sort=desc&action=tokentx&tag=latest&page=1`, + ) + .reply(200, { + status: '1', + result: [{ ...ETHERSCAN_TOKEN_TRANSACTION_MOCK, blockNumber: 2 }], + }) + .persist(); + + // start polling with two clients which share the same chainId + transactionController.startIncomingTransactionPolling([ + NetworkType.goerli, + otherGoerliClientNetworkClientId, + ]); + await advanceTime({ clock, duration: 1 }); + expect(fetchEtherscanNativeTxFetchSpy).toHaveBeenCalledTimes(1); + expect(fetchEtherscanTokenTxFetchSpy).toHaveBeenCalledTimes(0); + await advanceTime({ clock, duration: 4999 }); + // after 5 seconds we can call to the etherscan API again, this time to the token transactions endpoint + expect(fetchEtherscanNativeTxFetchSpy).toHaveBeenCalledTimes(1); + expect(fetchEtherscanTokenTxFetchSpy).toHaveBeenCalledTimes(1); + await advanceTime({ clock, duration: 5000 }); + // after another 5 seconds there should be no new calls to the etherscan API + // since no new blocks events have occurred + expect(fetchEtherscanNativeTxFetchSpy).toHaveBeenCalledTimes(1); + expect(fetchEtherscanTokenTxFetchSpy).toHaveBeenCalledTimes(1); + // next block arrives after 20 seconds elapsed from first call + await advanceTime({ clock, duration: 10000 }); + await advanceTime({ clock, duration: 1 }); // flushes extra promises/setTimeouts + // first the native transactions are fetched + expect(fetchEtherscanNativeTxFetchSpy).toHaveBeenCalledTimes(2); + expect(fetchEtherscanTokenTxFetchSpy).toHaveBeenCalledTimes(1); + await advanceTime({ clock, duration: 4000 }); + // no new calls to the etherscan API since 5 seconds have not passed + expect(fetchEtherscanNativeTxFetchSpy).toHaveBeenCalledTimes(2); + expect(fetchEtherscanTokenTxFetchSpy).toHaveBeenCalledTimes(1); + await advanceTime({ clock, duration: 1000 }); // flushes extra promises/setTimeouts + // then once 5 seconds have passed since the previous call to the etherscan API + // we call the token transactions endpoint + expect(fetchEtherscanNativeTxFetchSpy).toHaveBeenCalledTimes(2); + expect(fetchEtherscanTokenTxFetchSpy).toHaveBeenCalledTimes(2); + + transactionController.destroy(); + }); + }); + }); + + describe('stopIncomingTransactionPolling', () => { + it('should not poll for new incoming transactions for the given networkClientId', async () => { + const selectedAddress = ETHERSCAN_TRANSACTION_BASE_MOCK.to; + + const { networkController, transactionController } = await newController({ + getSelectedAddress: () => selectedAddress, + }); + + const networkClients = networkController.getNetworkClientRegistry(); + const networkClientIds = Object.keys(networkClients); + await Promise.all( + networkClientIds.map(async (networkClientId) => { + const config = networkClients[networkClientId].configuration; + mockNetwork({ + networkClientConfiguration: config, + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + ], + }); + nock(getEtherscanApiHost(config.chainId)) + .get( + `/api?module=account&address=${selectedAddress}&offset=40&sort=desc&action=txlist&tag=latest&page=1`, + ) + .reply(200, ETHERSCAN_TRANSACTION_RESPONSE_MOCK); + + transactionController.startIncomingTransactionPolling([ + networkClientId, + ]); + + transactionController.stopIncomingTransactionPolling([ + networkClientId, + ]); + }), + ); + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + + expect(transactionController.state.transactions).toStrictEqual([]); + expect(transactionController.state.lastFetchedBlockNumbers).toStrictEqual( + {}, + ); + transactionController.destroy(); + }); + + it('should stop the global incoming transaction helper when no networkClientIds provided', async () => { + const selectedAddress = ETHERSCAN_TRANSACTION_BASE_MOCK.to; + + const { transactionController } = await newController({ + getSelectedAddress: () => selectedAddress, + }); + + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + ], + }); + nock(getEtherscanApiHost(BUILT_IN_NETWORKS[NetworkType.mainnet].chainId)) + .get( + `/api?module=account&address=${selectedAddress}&offset=40&sort=desc&action=txlist&tag=latest&page=1`, + ) + .reply(200, ETHERSCAN_TRANSACTION_RESPONSE_MOCK); + + transactionController.startIncomingTransactionPolling(); + + transactionController.stopIncomingTransactionPolling(); + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + + expect(transactionController.state.transactions).toStrictEqual([]); + expect(transactionController.state.lastFetchedBlockNumbers).toStrictEqual( + {}, + ); + transactionController.destroy(); + }); + }); + + describe('stopAllIncomingTransactionPolling', () => { + it('should not poll for incoming transactions on any network client', async () => { + const selectedAddress = ETHERSCAN_TRANSACTION_BASE_MOCK.to; + + const { networkController, transactionController } = await newController({ + getSelectedAddress: () => selectedAddress, + }); + + const networkClients = networkController.getNetworkClientRegistry(); + const networkClientIds = Object.keys(networkClients); + await Promise.all( + networkClientIds.map(async (networkClientId) => { + const config = networkClients[networkClientId].configuration; + mockNetwork({ + networkClientConfiguration: config, + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthBlockNumberRequestMock('0x2'), + ], + }); + nock(getEtherscanApiHost(config.chainId)) + .get( + `/api?module=account&address=${selectedAddress}&offset=40&sort=desc&action=txlist&tag=latest&page=1`, + ) + .reply(200, ETHERSCAN_TRANSACTION_RESPONSE_MOCK); + + transactionController.startIncomingTransactionPolling([ + networkClientId, + ]); + }), + ); + + transactionController.stopAllIncomingTransactionPolling(); + await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); + + expect(transactionController.state.transactions).toStrictEqual([]); + expect(transactionController.state.lastFetchedBlockNumbers).toStrictEqual( + {}, + ); + transactionController.destroy(); + }); + }); + + describe('updateIncomingTransactions', () => { + it('should add incoming transactions to state with the correct chainId for the given networkClientId without waiting for the next block', async () => { + const selectedAddress = ETHERSCAN_TRANSACTION_BASE_MOCK.to; + + const { networkController, transactionController } = await newController({ + getSelectedAddress: () => selectedAddress, + }); + + const expectedLastFetchedBlockNumbers: Record = {}; + const expectedTransactions: Partial[] = []; + + const networkClients = networkController.getNetworkClientRegistry(); + const networkClientIds = Object.keys(networkClients); + await Promise.all( + networkClientIds.map(async (networkClientId) => { + const config = networkClients[networkClientId].configuration; + mockNetwork({ + networkClientConfiguration: config, + mocks: [buildEthBlockNumberRequestMock('0x1')], + }); + nock(getEtherscanApiHost(config.chainId)) + .get( + `/api?module=account&address=${selectedAddress}&offset=40&sort=desc&action=txlist&tag=latest&page=1`, + ) + .reply(200, ETHERSCAN_TRANSACTION_RESPONSE_MOCK); + + transactionController.updateIncomingTransactions([networkClientId]); + + expectedLastFetchedBlockNumbers[ + `${config.chainId}#${selectedAddress}#normal` + ] = parseInt(ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, 10); + expectedTransactions.push({ + blockNumber: ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, + chainId: config.chainId, + type: TransactionType.incoming, + verifiedOnBlockchain: false, + status: TransactionStatus.confirmed, + }); + expectedTransactions.push({ + blockNumber: ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, + chainId: config.chainId, + type: TransactionType.incoming, + verifiedOnBlockchain: false, + status: TransactionStatus.failed, + }); + }), + ); + + // we have to wait for the mutex to be released after the 5 second API rate limit timer + await advanceTime({ clock, duration: 1 }); + + expect(transactionController.state.transactions).toHaveLength( + 2 * networkClientIds.length, + ); + expect(transactionController.state.transactions).toStrictEqual( + expect.arrayContaining( + expectedTransactions.map(expect.objectContaining), + ), + ); + expect(transactionController.state.lastFetchedBlockNumbers).toStrictEqual( + expectedLastFetchedBlockNumbers, + ); + transactionController.destroy(); + }); + + it('should update the incoming transactions for the gloablly selected network when no networkClientIds provided', async () => { + const selectedAddress = ETHERSCAN_TRANSACTION_BASE_MOCK.to; + + const { transactionController } = await newController({ + getSelectedAddress: () => selectedAddress, + }); + + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + mocks: [buildEthBlockNumberRequestMock('0x1')], + }); + nock(getEtherscanApiHost(BUILT_IN_NETWORKS[NetworkType.mainnet].chainId)) + .get( + `/api?module=account&address=${selectedAddress}&offset=40&sort=desc&action=txlist&tag=latest&page=1`, + ) + .reply(200, ETHERSCAN_TRANSACTION_RESPONSE_MOCK); + + transactionController.updateIncomingTransactions(); + + // we have to wait for the mutex to be released after the 5 second API rate limit timer + await advanceTime({ clock, duration: 1 }); + + expect(transactionController.state.transactions).toHaveLength(2); + expect(transactionController.state.transactions).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + blockNumber: ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, + chainId: '0x1', + type: TransactionType.incoming, + verifiedOnBlockchain: false, + status: TransactionStatus.confirmed, + }), + expect.objectContaining({ + blockNumber: ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, + chainId: '0x1', + type: TransactionType.incoming, + verifiedOnBlockchain: false, + status: TransactionStatus.failed, + }), + ]), + ); + expect(transactionController.state.lastFetchedBlockNumbers).toStrictEqual( + { + [`0x1#${selectedAddress}#normal`]: parseInt( + ETHERSCAN_TRANSACTION_BASE_MOCK.blockNumber, + 10, + ), + }, + ); + transactionController.destroy(); + }); + }); + + describe('getNonceLock', () => { + it('should get the nonce lock from the nonceTracker for the given networkClientId', async () => { + const { networkController, transactionController } = await newController( + {}, + ); + + const networkClients = networkController.getNetworkClientRegistry(); + const networkClientIds = Object.keys(networkClients); + await Promise.all( + networkClientIds.map(async (networkClientId) => { + const config = networkClients[networkClientId].configuration; + mockNetwork({ + networkClientConfiguration: config, + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetTransactionCountRequestMock( + ACCOUNT_MOCK, + '0x1', + '0xa', + ), + ], + }); + + const nonceLockPromise = transactionController.getNonceLock( + ACCOUNT_MOCK, + networkClientId, + ); + await advanceTime({ clock, duration: 1 }); + + const nonceLock = await nonceLockPromise; + + expect(nonceLock.nextNonce).toBe(10); + }), + ); + transactionController.destroy(); + }); + + it('should block attempts to get the nonce lock for the same address from the nonceTracker for the networkClientId until the previous lock is released', async () => { + const { networkController, transactionController } = await newController( + {}, + ); + + const networkClients = networkController.getNetworkClientRegistry(); + const networkClientIds = Object.keys(networkClients); + await Promise.all( + networkClientIds.map(async (networkClientId) => { + const config = networkClients[networkClientId].configuration; + mockNetwork({ + networkClientConfiguration: config, + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetTransactionCountRequestMock( + ACCOUNT_MOCK, + '0x1', + '0xa', + ), + ], + }); + + const firstNonceLockPromise = transactionController.getNonceLock( + ACCOUNT_MOCK, + networkClientId, + ); + await advanceTime({ clock, duration: 1 }); + + const firstNonceLock = await firstNonceLockPromise; + + expect(firstNonceLock.nextNonce).toBe(10); + + const secondNonceLockPromise = transactionController.getNonceLock( + ACCOUNT_MOCK, + networkClientId, + ); + const delay = () => + new Promise(async (resolve) => { + await advanceTime({ clock, duration: 100 }); + resolve(null); + }); + + let secondNonceLockIfAcquired = await Promise.race([ + secondNonceLockPromise, + delay(), + ]); + expect(secondNonceLockIfAcquired).toBeNull(); + + await firstNonceLock.releaseLock(); + await advanceTime({ clock, duration: 1 }); + + secondNonceLockIfAcquired = await Promise.race([ + secondNonceLockPromise, + delay(), + ]); + expect(secondNonceLockIfAcquired?.nextNonce).toBe(10); + }), + ); + transactionController.destroy(); + }); + + it('should block attempts to get the nonce lock for the same address from the nonceTracker for the different networkClientIds on the same chainId until the previous lock is released', async () => { + const { networkController, transactionController } = await newController( + {}, + ); + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK, '0x1', '0xa'), + ], + }); + + mockNetwork({ + networkClientConfiguration: customGoerliNetworkClientConfiguration, + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK, '0x1', '0xa'), + ], + }); + + const otherNetworkClientIdOnGoerli = + await networkController.upsertNetworkConfiguration( + customGoerliNetworkClientConfiguration, + { + referrer: 'https://mock.referrer', + source: 'dapp', + }, + ); + + const firstNonceLockPromise = transactionController.getNonceLock( + ACCOUNT_MOCK, + 'goerli', + ); + await advanceTime({ clock, duration: 1 }); + + const firstNonceLock = await firstNonceLockPromise; + + expect(firstNonceLock.nextNonce).toBe(10); + + const secondNonceLockPromise = transactionController.getNonceLock( + ACCOUNT_MOCK, + otherNetworkClientIdOnGoerli, + ); + const delay = () => + new Promise(async (resolve) => { + await advanceTime({ clock, duration: 100 }); + resolve(null); + }); + + let secondNonceLockIfAcquired = await Promise.race([ + secondNonceLockPromise, + delay(), + ]); + expect(secondNonceLockIfAcquired).toBeNull(); + + await firstNonceLock.releaseLock(); + await advanceTime({ clock, duration: 1 }); + + secondNonceLockIfAcquired = await Promise.race([ + secondNonceLockPromise, + delay(), + ]); + expect(secondNonceLockIfAcquired?.nextNonce).toBe(10); + + transactionController.destroy(); + }); + + it('should not block attempts to get the nonce lock for the same addresses from the nonceTracker for different networkClientIds', async () => { + const { transactionController } = await newController({}); + + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.goerli, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK, '0x1', '0xa'), + ], + }); + + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.sepolia, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK, '0x1', '0xf'), + ], + }); + + const firstNonceLockPromise = transactionController.getNonceLock( + ACCOUNT_MOCK, + 'goerli', + ); + await advanceTime({ clock, duration: 1 }); + + const firstNonceLock = await firstNonceLockPromise; + + expect(firstNonceLock.nextNonce).toBe(10); + + const secondNonceLockPromise = transactionController.getNonceLock( + ACCOUNT_MOCK, + 'sepolia', + ); + await advanceTime({ clock, duration: 1 }); + + const secondNonceLock = await secondNonceLockPromise; + + expect(secondNonceLock.nextNonce).toBe(15); + + transactionController.destroy(); + }); + + it('should not block attempts to get the nonce lock for different addresses from the nonceTracker for the networkClientId', async () => { + const { networkController, transactionController } = await newController( + {}, + ); + + const networkClients = networkController.getNetworkClientRegistry(); + const networkClientIds = Object.keys(networkClients); + await Promise.all( + networkClientIds.map(async (networkClientId) => { + const config = networkClients[networkClientId].configuration; + mockNetwork({ + networkClientConfiguration: config, + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetTransactionCountRequestMock( + ACCOUNT_MOCK, + '0x1', + '0xa', + ), + buildEthGetTransactionCountRequestMock( + ACCOUNT_2_MOCK, + '0x1', + '0xf', + ), + ], + }); + + const firstNonceLockPromise = transactionController.getNonceLock( + ACCOUNT_MOCK, + networkClientId, + ); + await advanceTime({ clock, duration: 1 }); + + const firstNonceLock = await firstNonceLockPromise; + + expect(firstNonceLock.nextNonce).toBe(10); + + const secondNonceLockPromise = transactionController.getNonceLock( + ACCOUNT_2_MOCK, + networkClientId, + ); + await advanceTime({ clock, duration: 1 }); + + const secondNonceLock = await secondNonceLockPromise; + + expect(secondNonceLock.nextNonce).toBe(15); + }), + ); + transactionController.destroy(); + }); + + it('should get the nonce lock from the globally selected nonceTracker if no networkClientId is provided', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK, '0x1', '0xa'), + ], + }); + + const { transactionController } = await newController({}); + + const nonceLockPromise = transactionController.getNonceLock(ACCOUNT_MOCK); + await advanceTime({ clock, duration: 1 }); + + const nonceLock = await nonceLockPromise; + + expect(nonceLock.nextNonce).toBe(10); + transactionController.destroy(); + }); + + it('should block attempts to get the nonce lock from the globally selected NonceTracker for the same address until the previous lock is released', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK, '0x1', '0xa'), + ], + }); + + const { transactionController } = await newController({}); + + const firstNonceLockPromise = + transactionController.getNonceLock(ACCOUNT_MOCK); + await advanceTime({ clock, duration: 1 }); + + const firstNonceLock = await firstNonceLockPromise; + + expect(firstNonceLock.nextNonce).toBe(10); + + const secondNonceLockPromise = + transactionController.getNonceLock(ACCOUNT_MOCK); + const delay = () => + new Promise(async (resolve) => { + await advanceTime({ clock, duration: 100 }); + resolve(null); + }); + + let secondNonceLockIfAcquired = await Promise.race([ + secondNonceLockPromise, + delay(), + ]); + expect(secondNonceLockIfAcquired).toBeNull(); + + await firstNonceLock.releaseLock(); + + secondNonceLockIfAcquired = await Promise.race([ + secondNonceLockPromise, + delay(), + ]); + expect(secondNonceLockIfAcquired?.nextNonce).toBe(10); + transactionController.destroy(); + }); + + it('should not block attempts to get the nonce lock from the globally selected nonceTracker for different addresses', async () => { + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + mocks: [ + buildEthBlockNumberRequestMock('0x1'), + buildEthGetTransactionCountRequestMock(ACCOUNT_MOCK, '0x1', '0xa'), + buildEthGetTransactionCountRequestMock(ACCOUNT_2_MOCK, '0x1', '0xf'), + ], + }); + + const { transactionController } = await newController({}); + + const firstNonceLockPromise = + transactionController.getNonceLock(ACCOUNT_MOCK); + await advanceTime({ clock, duration: 1 }); + + const firstNonceLock = await firstNonceLockPromise; + + expect(firstNonceLock.nextNonce).toBe(10); + + const secondNonceLockPromise = + transactionController.getNonceLock(ACCOUNT_2_MOCK); + await advanceTime({ clock, duration: 1 }); + + const secondNonceLock = await secondNonceLockPromise; + + expect(secondNonceLock.nextNonce).toBe(15); + + transactionController.destroy(); + }); + }); +}); diff --git a/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.test.ts new file mode 100644 index 0000000000..62ab44f949 --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.test.ts @@ -0,0 +1,152 @@ +import type EthQuery from '@metamask/eth-query'; +import type { + GasFeeEstimates as FeeMarketGasPriceEstimate, + GasFeeState, + LegacyGasPriceEstimate, +} from '@metamask/gas-fee-controller'; +import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; + +import type { GasFeeEstimates, TransactionMeta } from '../types'; +import { TransactionStatus } from '../types'; +import { DefaultGasFeeFlow } from './DefaultGasFeeFlow'; + +const ETH_QUERY_MOCK = {} as EthQuery; + +const TRANSACTION_META_MOCK: TransactionMeta = { + id: '1', + chainId: '0x123', + status: TransactionStatus.unapproved, + time: 0, + txParams: { + from: '0x123', + }, +}; + +const FEE_MARKET_ESTIMATES_MOCK = { + low: { + suggestedMaxFeePerGas: '1', + suggestedMaxPriorityFeePerGas: '2', + }, + medium: { + suggestedMaxFeePerGas: '3', + suggestedMaxPriorityFeePerGas: '4', + }, + high: { + suggestedMaxFeePerGas: '5', + suggestedMaxPriorityFeePerGas: '6', + }, +} as FeeMarketGasPriceEstimate; + +const LEGACY_ESTIMATES_MOCK: LegacyGasPriceEstimate = { + low: '1', + medium: '3', + high: '5', +}; + +const FEE_MARKET_RESPONSE_MOCK = { + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + gasFeeEstimates: FEE_MARKET_ESTIMATES_MOCK, +} as GasFeeState; + +const LEGACY_RESPONSE_MOCK = { + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, + gasFeeEstimates: LEGACY_ESTIMATES_MOCK, +} as GasFeeState; + +// Converted to Hex and multiplied by 1 billion. +const FEE_MARKET_EXPECTED_RESULT: GasFeeEstimates = { + low: { + maxFeePerGas: '0x3b9aca00', + maxPriorityFeePerGas: '0x77359400', + }, + medium: { + maxFeePerGas: '0xb2d05e00', + maxPriorityFeePerGas: '0xee6b2800', + }, + high: { + maxFeePerGas: '0x12a05f200', + maxPriorityFeePerGas: '0x165a0bc00', + }, +}; + +// Converted to Hex and multiplied by 1 billion. +const LEGACY_EXPECTED_RESULT: GasFeeEstimates = { + low: { + maxFeePerGas: '0x3b9aca00', + maxPriorityFeePerGas: '0x3b9aca00', + }, + medium: { + maxFeePerGas: '0xb2d05e00', + maxPriorityFeePerGas: '0xb2d05e00', + }, + high: { + maxFeePerGas: '0x12a05f200', + maxPriorityFeePerGas: '0x12a05f200', + }, +}; + +describe('DefaultGasFeeFlow', () => { + describe('matchesTransaction', () => { + it('returns true', () => { + const defaultGasFeeFlow = new DefaultGasFeeFlow(); + const result = defaultGasFeeFlow.matchesTransaction( + TRANSACTION_META_MOCK, + ); + expect(result).toBe(true); + }); + }); + + describe('getGasFees', () => { + it('returns fee market values if estimate type is fee market', async () => { + const defaultGasFeeFlow = new DefaultGasFeeFlow(); + + const getGasFeeControllerEstimates = jest + .fn() + .mockResolvedValue(FEE_MARKET_RESPONSE_MOCK); + + const response = await defaultGasFeeFlow.getGasFees({ + ethQuery: ETH_QUERY_MOCK, + getGasFeeControllerEstimates, + transactionMeta: TRANSACTION_META_MOCK, + }); + + expect(response).toStrictEqual({ + estimates: FEE_MARKET_EXPECTED_RESULT, + }); + }); + + it('returns legacy values if estimate type is legacy', async () => { + const defaultGasFeeFlow = new DefaultGasFeeFlow(); + + const getGasFeeControllerEstimates = jest + .fn() + .mockResolvedValue(LEGACY_RESPONSE_MOCK); + + const response = await defaultGasFeeFlow.getGasFees({ + ethQuery: ETH_QUERY_MOCK, + getGasFeeControllerEstimates, + transactionMeta: TRANSACTION_META_MOCK, + }); + + expect(response).toStrictEqual({ + estimates: LEGACY_EXPECTED_RESULT, + }); + }); + + it('throws if estimate type not supported', async () => { + const defaultGasFeeFlow = new DefaultGasFeeFlow(); + + const getGasFeeControllerEstimates = jest.fn().mockResolvedValue({ + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + }); + + const response = defaultGasFeeFlow.getGasFees({ + ethQuery: ETH_QUERY_MOCK, + getGasFeeControllerEstimates, + transactionMeta: TRANSACTION_META_MOCK, + }); + + await expect(response).rejects.toThrow('No gas fee estimates available'); + }); + }); +}); diff --git a/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts new file mode 100644 index 0000000000..62287dd374 --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts @@ -0,0 +1,115 @@ +import type { + LegacyGasPriceEstimate, + GasFeeEstimates as FeeMarketGasPriceEstimate, +} from '@metamask/gas-fee-controller'; +import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../logger'; +import type { + GasFeeEstimates, + GasFeeEstimatesForLevel, + GasFeeFlow, + GasFeeFlowRequest, + GasFeeFlowResponse, + TransactionMeta, +} from '../types'; +import { GasFeeEstimateLevel } from '../types'; +import { gweiDecimalToWeiHex } from '../utils/gas-fees'; + +const log = createModuleLogger(projectLogger, 'default-gas-fee-flow'); + +type FeeMarketGetEstimateLevelRequest = { + gasEstimateType: 'fee-market'; + gasFeeEstimates: FeeMarketGasPriceEstimate; + level: GasFeeEstimateLevel; +}; + +type LegacyGetEstimateLevelRequest = { + gasEstimateType: 'legacy'; + gasFeeEstimates: LegacyGasPriceEstimate; + level: GasFeeEstimateLevel; +}; + +/** + * The standard implementation of a gas fee flow that obtains gas fee estimates using only the GasFeeController. + */ +export class DefaultGasFeeFlow implements GasFeeFlow { + matchesTransaction(_transactionMeta: TransactionMeta): boolean { + return true; + } + + async getGasFees(request: GasFeeFlowRequest): Promise { + const { getGasFeeControllerEstimates, transactionMeta } = request; + const { networkClientId } = transactionMeta; + + const { gasEstimateType, gasFeeEstimates } = + await getGasFeeControllerEstimates({ networkClientId }); + + if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { + log('Using fee market estimates', gasFeeEstimates); + } else if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { + log('Using legacy estimates', gasFeeEstimates); + } else { + throw new Error(`'No gas fee estimates available`); + } + + const estimates = Object.values(GasFeeEstimateLevel).reduce( + (result, level) => ({ + ...result, + [level]: this.#getEstimateLevel({ + gasEstimateType, + gasFeeEstimates, + level, + } as FeeMarketGetEstimateLevelRequest | LegacyGetEstimateLevelRequest), + }), + {} as GasFeeEstimates, + ); + + return { estimates }; + } + + #getEstimateLevel({ + gasEstimateType, + gasFeeEstimates, + level, + }: + | FeeMarketGetEstimateLevelRequest + | LegacyGetEstimateLevelRequest): GasFeeEstimatesForLevel { + if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { + return this.#getFeeMarketLevel(gasFeeEstimates, level); + } + + return this.#getLegacyLevel(gasFeeEstimates, level); + } + + #getFeeMarketLevel( + gasFeeEstimates: FeeMarketGasPriceEstimate, + level: GasFeeEstimateLevel, + ): GasFeeEstimatesForLevel { + const maxFeePerGas = gweiDecimalToWeiHex( + gasFeeEstimates[level].suggestedMaxFeePerGas, + ); + + const maxPriorityFeePerGas = gweiDecimalToWeiHex( + gasFeeEstimates[level].suggestedMaxPriorityFeePerGas, + ); + + return { + maxFeePerGas, + maxPriorityFeePerGas, + }; + } + + #getLegacyLevel( + gasFeeEstimates: LegacyGasPriceEstimate, + level: GasFeeEstimateLevel, + ): GasFeeEstimatesForLevel { + const gasPrice = gweiDecimalToWeiHex(gasFeeEstimates[level]); + + return { + maxFeePerGas: gasPrice, + maxPriorityFeePerGas: gasPrice, + }; + } +} diff --git a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts new file mode 100644 index 0000000000..01281c9721 --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts @@ -0,0 +1,153 @@ +import { query } from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; +import type { GasFeeState } from '@metamask/gas-fee-controller'; + +import { CHAIN_IDS } from '../constants'; +import type { + GasFeeFlowRequest, + GasFeeFlowResponse, + TransactionMeta, +} from '../types'; +import { TransactionStatus } from '../types'; +import { DefaultGasFeeFlow } from './DefaultGasFeeFlow'; +import { LineaGasFeeFlow } from './LineaGasFeeFlow'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + query: jest.fn(), +})); + +const TRANSACTION_META_MOCK: TransactionMeta = { + id: '1', + chainId: '0x123', + status: TransactionStatus.unapproved, + time: 0, + txParams: { + from: '0x123', + }, +}; + +const LINEA_RESPONSE_MOCK = { + baseFeePerGas: '0x111111111', + priorityFeePerGas: '0x222222222', +}; + +const RESPONSE_MOCK: GasFeeFlowResponse = { + estimates: { + low: { + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x2', + }, + medium: { + maxFeePerGas: '0x3', + maxPriorityFeePerGas: '0x4', + }, + high: { + maxFeePerGas: '0x5', + maxPriorityFeePerGas: '0x6', + }, + }, +}; + +describe('LineaGasFeeFlow', () => { + const queryMock = jest.mocked(query); + + let request: GasFeeFlowRequest; + let getGasFeeControllerEstimatesMock: jest.MockedFn< + () => Promise + >; + + beforeEach(() => { + jest.resetAllMocks(); + + request = { + ethQuery: {} as EthQuery, + getGasFeeControllerEstimates: getGasFeeControllerEstimatesMock, + transactionMeta: TRANSACTION_META_MOCK, + }; + + queryMock.mockResolvedValue(LINEA_RESPONSE_MOCK); + }); + + describe('matchesTransaction', () => { + it.each([ + ['linea mainnet', CHAIN_IDS.LINEA_MAINNET], + ['linea testnet', CHAIN_IDS.LINEA_GOERLI], + ])('returns true if chain ID is %s', (_title, chainId) => { + const flow = new LineaGasFeeFlow(); + + const transaction = { + ...TRANSACTION_META_MOCK, + chainId, + }; + + expect(flow.matchesTransaction(transaction)).toBe(true); + }); + }); + + describe('getGasFees', () => { + it('returns priority fees using custom RPC method and static priority fee multipliers', async () => { + const flow = new LineaGasFeeFlow(); + const response = await flow.getGasFees(request); + + expect( + Object.values(response.estimates).map( + (level) => level.maxPriorityFeePerGas, + ), + ).toStrictEqual([ + LINEA_RESPONSE_MOCK.priorityFeePerGas, + '0x23a3d70a3', + '0x25658bf25', + ]); + }); + + it('returns max fees using custom RPC method and static base fee multipliers', async () => { + const flow = new LineaGasFeeFlow(); + const response = await flow.getGasFees(request); + + expect( + Object.values(response.estimates).map((level) => level.maxFeePerGas), + ).toStrictEqual(['0x333333333', '0x3a7ae1479', '0x42428f5c1']); + }); + + it('uses default flow if error', async () => { + jest + .spyOn(DefaultGasFeeFlow.prototype, 'getGasFees') + .mockResolvedValue(RESPONSE_MOCK); + + const defaultGasFeeFlowGetGasFeesMock = jest.mocked( + DefaultGasFeeFlow.prototype.getGasFees, + ); + + queryMock.mockRejectedValue(new Error('TestError')); + + const flow = new LineaGasFeeFlow(); + const response = await flow.getGasFees(request); + + expect(response).toStrictEqual(RESPONSE_MOCK); + + expect(defaultGasFeeFlowGetGasFeesMock).toHaveBeenCalledTimes(1); + expect(defaultGasFeeFlowGetGasFeesMock).toHaveBeenCalledWith(request); + }); + + it('throws if default flow fallback fails', async () => { + jest + .spyOn(DefaultGasFeeFlow.prototype, 'getGasFees') + .mockRejectedValue(new Error('TestError')); + + const defaultGasFeeFlowGetGasFeesMock = jest.mocked( + DefaultGasFeeFlow.prototype.getGasFees, + ); + + queryMock.mockRejectedValue(new Error('error')); + + const flow = new LineaGasFeeFlow(); + const response = flow.getGasFees(request); + + await expect(response).rejects.toThrow('TestError'); + + expect(defaultGasFeeFlowGetGasFeesMock).toHaveBeenCalledTimes(1); + expect(defaultGasFeeFlowGetGasFeesMock).toHaveBeenCalledWith(request); + }); + }); +}); diff --git a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts new file mode 100644 index 0000000000..a6ebeed574 --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts @@ -0,0 +1,156 @@ +import { ChainId, hexToBN, query, toHex } from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; +import { createModuleLogger, type Hex } from '@metamask/utils'; +import type BN from 'bn.js'; + +import { projectLogger } from '../logger'; +import type { + GasFeeEstimates, + GasFeeFlow, + GasFeeFlowRequest, + GasFeeFlowResponse, + TransactionMeta, +} from '../types'; +import { GasFeeEstimateLevel } from '../types'; +import { DefaultGasFeeFlow } from './DefaultGasFeeFlow'; + +type LineaEstimateGasResponse = { + baseFeePerGas: Hex; + priorityFeePerGas: Hex; +}; + +type FeesByLevel = { + [key in GasFeeEstimateLevel]: BN; +}; + +const log = createModuleLogger(projectLogger, 'linea-gas-fee-flow'); + +const LINEA_CHAIN_IDS: Hex[] = [ + ChainId['linea-mainnet'], + ChainId['linea-goerli'], +]; + +const BASE_FEE_MULTIPLIERS = { + low: 1, + medium: 1.35, + high: 1.7, +}; + +const PRIORITY_FEE_MULTIPLIERS = { + low: 1, + medium: 1.05, + high: 1.1, +}; + +/** + * Implementation of a gas fee flow specific to Linea networks that obtains gas fee estimates using: + * - The `linea_estimateGas` RPC method to obtain the base fee and lowest priority fee. + * - Static multipliers to increase the base and priority fees. + */ +export class LineaGasFeeFlow implements GasFeeFlow { + matchesTransaction(transactionMeta: TransactionMeta): boolean { + return LINEA_CHAIN_IDS.includes(transactionMeta.chainId); + } + + async getGasFees(request: GasFeeFlowRequest): Promise { + try { + return await this.#getLineaGasFees(request); + } catch (error) { + log('Using default flow as fallback due to error', error); + return new DefaultGasFeeFlow().getGasFees(request); + } + } + + async #getLineaGasFees( + request: GasFeeFlowRequest, + ): Promise { + const { ethQuery, transactionMeta } = request; + + const lineaResponse = await this.#getLineaResponse( + transactionMeta, + ethQuery, + ); + + log('Received Linea response', lineaResponse); + + const baseFees = this.#getValuesFromMultipliers( + lineaResponse.baseFeePerGas, + BASE_FEE_MULTIPLIERS, + ); + + log('Generated base fees', this.#feesToString(baseFees)); + + const priorityFees = this.#getValuesFromMultipliers( + lineaResponse.priorityFeePerGas, + PRIORITY_FEE_MULTIPLIERS, + ); + + log('Generated priority fees', this.#feesToString(priorityFees)); + + const maxFees = this.#getMaxFees(baseFees, priorityFees); + + log('Generated max fees', this.#feesToString(maxFees)); + + const estimates = Object.values(GasFeeEstimateLevel).reduce( + (result, level) => ({ + ...result, + [level]: { + maxFeePerGas: toHex(maxFees[level]), + maxPriorityFeePerGas: toHex(priorityFees[level]), + }, + }), + {} as GasFeeEstimates, + ); + + return { estimates }; + } + + #getLineaResponse( + transactionMeta: TransactionMeta, + ethQuery: EthQuery, + ): Promise { + return query(ethQuery, 'linea_estimateGas', [ + { + from: transactionMeta.txParams.from, + to: transactionMeta.txParams.to, + value: transactionMeta.txParams.value, + input: transactionMeta.txParams.data, + // Required in request but no impact on response. + gasPrice: '0x100000000', + }, + ]); + } + + #getValuesFromMultipliers( + value: Hex, + multipliers: { low: number; medium: number; high: number }, + ): FeesByLevel { + const base = hexToBN(value); + const low = base.muln(multipliers.low); + const medium = base.muln(multipliers.medium); + const high = base.muln(multipliers.high); + + return { + low, + medium, + high, + }; + } + + #getMaxFees( + baseFees: Record, + priorityFees: Record, + ): FeesByLevel { + return { + low: baseFees.low.add(priorityFees.low), + medium: baseFees.medium.add(priorityFees.medium), + high: baseFees.high.add(priorityFees.high), + }; + } + + #feesToString(fees: FeesByLevel) { + return Object.values(GasFeeEstimateLevel).map((level) => + fees[level].toString(10), + ); + } +} diff --git a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts index 617d6765f9..ebddb6b370 100644 --- a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts +++ b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts @@ -1,13 +1,18 @@ import { v1 as random } from 'uuid'; +import { + ID_MOCK, + ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK, + ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_EMPTY_MOCK, + ETHERSCAN_TRANSACTION_RESPONSE_MOCK, + EXPECTED_NORMALISED_TRANSACTION_SUCCESS, + EXPECTED_NORMALISED_TRANSACTION_ERROR, + ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK, + EXPECTED_NORMALISED_TOKEN_TRANSACTION, + ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_ERROR_MOCK, + ETHERSCAN_TRANSACTION_RESPONSE_ERROR_MOCK, +} from '../../tests/EtherscanMocks'; import { CHAIN_IDS } from '../constants'; -import { TransactionStatus, TransactionType } from '../types'; -import type { - EtherscanTokenTransactionMeta, - EtherscanTransactionMeta, - EtherscanTransactionMetaBase, - EtherscanTransactionResponse, -} from '../utils/etherscan'; import { fetchEtherscanTokenTransactions, fetchEtherscanTransactions, @@ -21,133 +26,6 @@ jest.mock('../utils/etherscan', () => ({ jest.mock('uuid'); -const ID_MOCK = '6843ba00-f4bf-11e8-a715-5f2fff84549d'; - -const ETHERSCAN_TRANSACTION_BASE_MOCK: EtherscanTransactionMetaBase = { - blockNumber: '4535105', - confirmations: '4', - contractAddress: '', - cumulativeGasUsed: '693910', - from: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207', - gas: '335208', - gasPrice: '20000000000', - gasUsed: '21000', - hash: '0x342e9d73e10004af41d04973339fc7219dbadcbb5629730cfe65e9f9cb15ff91', - nonce: '1', - timeStamp: '1543596356', - transactionIndex: '13', - value: '50000000000000000', - blockHash: '0x0000000001', - to: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207', -}; - -const ETHERSCAN_TRANSACTION_SUCCESS_MOCK: EtherscanTransactionMeta = { - ...ETHERSCAN_TRANSACTION_BASE_MOCK, - functionName: 'testFunction', - input: '0x', - isError: '0', - methodId: 'testId', - txreceipt_status: '1', -}; - -const ETHERSCAN_TRANSACTION_ERROR_MOCK: EtherscanTransactionMeta = { - ...ETHERSCAN_TRANSACTION_SUCCESS_MOCK, - isError: '1', -}; - -const ETHERSCAN_TOKEN_TRANSACTION_MOCK: EtherscanTokenTransactionMeta = { - ...ETHERSCAN_TRANSACTION_BASE_MOCK, - tokenDecimal: '456', - tokenName: 'TestToken', - tokenSymbol: 'ABC', -}; - -const ETHERSCAN_TRANSACTION_RESPONSE_MOCK: EtherscanTransactionResponse = - { - status: '1', - result: [ - ETHERSCAN_TRANSACTION_SUCCESS_MOCK, - ETHERSCAN_TRANSACTION_ERROR_MOCK, - ], - }; - -const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK: EtherscanTransactionResponse = - { - status: '1', - result: [ - ETHERSCAN_TOKEN_TRANSACTION_MOCK, - ETHERSCAN_TOKEN_TRANSACTION_MOCK, - ], - }; - -const ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK: EtherscanTransactionResponse = - { - status: '0', - result: '', - }; - -const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_EMPTY_MOCK: EtherscanTransactionResponse = - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK as any; - -const ETHERSCAN_TRANSACTION_RESPONSE_ERROR_MOCK: EtherscanTransactionResponse = - { - status: '0', - message: 'NOTOK', - result: 'Test Error', - }; - -const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_ERROR_MOCK: EtherscanTransactionResponse = - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ETHERSCAN_TRANSACTION_RESPONSE_ERROR_MOCK as any; - -const EXPECTED_NORMALISED_TRANSACTION_BASE = { - blockNumber: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.blockNumber, - chainId: undefined, - hash: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.hash, - id: ID_MOCK, - status: TransactionStatus.confirmed, - time: 1543596356000, - txParams: { - chainId: undefined, - from: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.from, - gas: '0x51d68', - gasPrice: '0x4a817c800', - gasUsed: '0x5208', - nonce: '0x1', - to: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.to, - value: '0xb1a2bc2ec50000', - }, - type: TransactionType.incoming, - verifiedOnBlockchain: false, -}; - -const EXPECTED_NORMALISED_TRANSACTION_SUCCESS = { - ...EXPECTED_NORMALISED_TRANSACTION_BASE, - txParams: { - ...EXPECTED_NORMALISED_TRANSACTION_BASE.txParams, - data: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.input, - }, -}; - -const EXPECTED_NORMALISED_TRANSACTION_ERROR = { - ...EXPECTED_NORMALISED_TRANSACTION_SUCCESS, - error: new Error('Transaction failed'), - status: TransactionStatus.failed, -}; - -const EXPECTED_NORMALISED_TOKEN_TRANSACTION = { - ...EXPECTED_NORMALISED_TRANSACTION_BASE, - isTransfer: true, - transferInformation: { - contractAddress: '', - decimals: Number(ETHERSCAN_TOKEN_TRANSACTION_MOCK.tokenDecimal), - symbol: ETHERSCAN_TOKEN_TRANSACTION_MOCK.tokenSymbol, - }, -}; - describe('EtherscanRemoteTransactionSource', () => { const fetchEtherscanTransactionsMock = fetchEtherscanTransactions as jest.MockedFn< diff --git a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts index bf320bc8ee..651eec110e 100644 --- a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts +++ b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts @@ -1,6 +1,7 @@ import { BNToHex } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; -import { BN } from 'ethereumjs-util'; +import { Mutex } from 'async-mutex'; +import BN from 'bn.js'; import { v1 as random } from 'uuid'; import { ETHERSCAN_SUPPORTED_NETWORKS } from '../constants'; @@ -23,6 +24,7 @@ import type { EtherscanTransactionResponse, } from '../utils/etherscan'; +const ETHERSCAN_RATE_LIMIT_INTERVAL = 5000; /** * A RemoteTransactionSource that fetches transaction data from Etherscan. */ @@ -33,6 +35,8 @@ export class EtherscanRemoteTransactionSource #isTokenRequestPending: boolean; + #mutex = new Mutex(); + constructor({ includeTokenTransfers, }: { includeTokenTransfers?: boolean } = {}) { @@ -51,20 +55,41 @@ export class EtherscanRemoteTransactionSource async fetchTransactions( request: RemoteTransactionSourceRequest, ): Promise { + const releaseLock = await this.#mutex.acquire(); + const acquiredTime = Date.now(); + const etherscanRequest: EtherscanTransactionRequest = { ...request, chainId: request.currentChainId, }; - const transactions = this.#isTokenRequestPending - ? await this.#fetchTokenTransactions(request, etherscanRequest) - : await this.#fetchNormalTransactions(request, etherscanRequest); + try { + const transactions = this.#isTokenRequestPending + ? await this.#fetchTokenTransactions(request, etherscanRequest) + : await this.#fetchNormalTransactions(request, etherscanRequest); + + if (this.#includeTokenTransfers) { + this.#isTokenRequestPending = !this.#isTokenRequestPending; + } - if (this.#includeTokenTransfers) { - this.#isTokenRequestPending = !this.#isTokenRequestPending; + return transactions; + } finally { + this.#releaseLockAfterInterval(acquiredTime, releaseLock); } + } - return transactions; + #releaseLockAfterInterval(acquireTime: number, releaseLock: () => void) { + const elapsedTime = Date.now() - acquireTime; + const remainingTime = Math.max( + 0, + ETHERSCAN_RATE_LIMIT_INTERVAL - elapsedTime, + ); + // Wait for the remaining time if it hasn't been 5 seconds yet + if (remainingTime > 0) { + setTimeout(releaseLock, remainingTime); + } else { + releaseLock(); + } } #fetchNormalTransactions = async ( diff --git a/packages/transaction-controller/src/helpers/GasFeePoller.test.ts b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts new file mode 100644 index 0000000000..06485103fb --- /dev/null +++ b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts @@ -0,0 +1,229 @@ +import type EthQuery from '@metamask/eth-query'; +import type { Hex } from '@metamask/utils'; + +import { flushPromises } from '../../../../tests/helpers'; +import type { GasFeeFlowResponse } from '../types'; +import { + TransactionStatus, + type GasFeeFlow, + type TransactionMeta, +} from '../types'; +import { GasFeePoller } from './GasFeePoller'; + +jest.useFakeTimers(); + +const CHAIN_ID_MOCK: Hex = '0x123'; + +const TRANSACTION_META_MOCK: TransactionMeta = { + id: '1', + chainId: CHAIN_ID_MOCK, + status: TransactionStatus.unapproved, + time: 0, + txParams: { + from: '0x123', + }, +}; + +const GAS_FEE_FLOW_RESPONSE_MOCK: GasFeeFlowResponse = { + estimates: { + low: { maxFeePerGas: '0x1', maxPriorityFeePerGas: '0x2' }, + medium: { + maxFeePerGas: '0x3', + maxPriorityFeePerGas: '0x4', + }, + high: { + maxFeePerGas: '0x5', + maxPriorityFeePerGas: '0x6', + }, + }, +}; + +/** + * Creates a mock GasFeeFlow. + * @returns The mock GasFeeFlow. + */ +function createGasFeeFlowMock(): jest.Mocked { + return { + matchesTransaction: jest.fn(), + getGasFees: jest.fn(), + }; +} + +describe('GasFeePoller', () => { + let constructorOptions: ConstructorParameters[0]; + let gasFeeFlowMock: jest.Mocked; + let triggerOnStateChange: () => void; + let getTransactionsMock: jest.MockedFunction<() => TransactionMeta[]>; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllTimers(); + + gasFeeFlowMock = createGasFeeFlowMock(); + gasFeeFlowMock.matchesTransaction.mockReturnValue(true); + gasFeeFlowMock.getGasFees.mockResolvedValue(GAS_FEE_FLOW_RESPONSE_MOCK); + + getTransactionsMock = jest.fn(); + getTransactionsMock.mockReturnValue([TRANSACTION_META_MOCK]); + + constructorOptions = { + gasFeeFlows: [gasFeeFlowMock], + getEthQuery: () => ({} as EthQuery), + getGasFeeControllerEstimates: jest.fn(), + getTransactions: getTransactionsMock, + onStateChange: (listener: () => void) => { + triggerOnStateChange = listener; + }, + }; + }); + + describe('on state change', () => { + describe('if unapproved transaction', () => { + it('emits updated event', async () => { + const listener = jest.fn(); + + const gasFeePoller = new GasFeePoller(constructorOptions); + gasFeePoller.hub.on('transaction-updated', listener); + + triggerOnStateChange(); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + ...TRANSACTION_META_MOCK, + gasFeeEstimates: GAS_FEE_FLOW_RESPONSE_MOCK.estimates, + gasFeeEstimatesLoaded: true, + }); + }); + + it('calls gas fee flow', async () => { + const listener = jest.fn(); + + const gasFeePoller = new GasFeePoller(constructorOptions); + gasFeePoller.hub.on('transaction-updated', listener); + + triggerOnStateChange(); + await flushPromises(); + + expect(gasFeeFlowMock.getGasFees).toHaveBeenCalledTimes(1); + expect(gasFeeFlowMock.getGasFees).toHaveBeenCalledWith({ + ethQuery: expect.any(Object), + getGasFeeControllerEstimates: + constructorOptions.getGasFeeControllerEstimates, + transactionMeta: TRANSACTION_META_MOCK, + }); + }); + + it('creates polling timeout', async () => { + new GasFeePoller(constructorOptions); + + triggerOnStateChange(); + await flushPromises(); + + expect(jest.getTimerCount()).toBe(1); + + jest.runOnlyPendingTimers(); + await flushPromises(); + + expect(gasFeeFlowMock.getGasFees).toHaveBeenCalledTimes(2); + }); + + it('does not create additional polling timeout on subsequent state changes', async () => { + new GasFeePoller(constructorOptions); + + triggerOnStateChange(); + await flushPromises(); + + triggerOnStateChange(); + await flushPromises(); + + expect(jest.getTimerCount()).toBe(1); + }); + }); + + describe('does nothing if', () => { + it('no transactions', async () => { + const listener = jest.fn(); + + getTransactionsMock.mockReturnValue([]); + + const gasFeePoller = new GasFeePoller(constructorOptions); + gasFeePoller.hub.on('transaction-updated', listener); + + triggerOnStateChange(); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + it('transaction has alternate status', async () => { + const listener = jest.fn(); + + getTransactionsMock.mockReturnValue([ + { + ...TRANSACTION_META_MOCK, + status: TransactionStatus.submitted, + }, + ]); + + const gasFeePoller = new GasFeePoller(constructorOptions); + gasFeePoller.hub.on('transaction-updated', listener); + + triggerOnStateChange(); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + it('no gas fee flow matches transaction and already loaded', async () => { + const listener = jest.fn(); + + gasFeeFlowMock.matchesTransaction.mockReturnValue(false); + + getTransactionsMock.mockReturnValue([ + { ...TRANSACTION_META_MOCK, gasFeeEstimatesLoaded: true }, + ]); + + const gasFeePoller = new GasFeePoller(constructorOptions); + gasFeePoller.hub.on('transaction-updated', listener); + + triggerOnStateChange(); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + it('gas fee flow throws and already loaded', async () => { + const listener = jest.fn(); + + gasFeeFlowMock.getGasFees.mockRejectedValue(new Error('TestError')); + + getTransactionsMock.mockReturnValue([ + { ...TRANSACTION_META_MOCK, gasFeeEstimatesLoaded: true }, + ]); + + const gasFeePoller = new GasFeePoller(constructorOptions); + gasFeePoller.hub.on('transaction-updated', listener); + + triggerOnStateChange(); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(0); + }); + }); + + it('clears polling timeout if no transactions', async () => { + new GasFeePoller(constructorOptions); + + triggerOnStateChange(); + await flushPromises(); + + getTransactionsMock.mockReturnValue([]); + + triggerOnStateChange(); + await flushPromises(); + + expect(jest.getTimerCount()).toBe(0); + }); + }); +}); diff --git a/packages/transaction-controller/src/helpers/GasFeePoller.ts b/packages/transaction-controller/src/helpers/GasFeePoller.ts new file mode 100644 index 0000000000..52343389b5 --- /dev/null +++ b/packages/transaction-controller/src/helpers/GasFeePoller.ts @@ -0,0 +1,174 @@ +import type EthQuery from '@metamask/eth-query'; +import type { GasFeeState } from '@metamask/gas-fee-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import EventEmitter from 'events'; + +import type { NetworkClientId } from '../../../network-controller/src'; +import { projectLogger } from '../logger'; +import type { GasFeeEstimates, GasFeeFlow, GasFeeFlowRequest } from '../types'; +import { TransactionStatus, type TransactionMeta } from '../types'; +import { getGasFeeFlow } from '../utils/gas-flow'; + +const log = createModuleLogger(projectLogger, 'gas-fee-poller'); + +const INTERVAL_MILLISECONDS = 10000; + +/** + * Automatically polls and updates suggested gas fees on unapproved transactions. + */ +export class GasFeePoller { + hub: EventEmitter = new EventEmitter(); + + #gasFeeFlows: GasFeeFlow[]; + + #getEthQuery: (chainId: Hex, networkClientId?: NetworkClientId) => EthQuery; + + #getGasFeeControllerEstimates: () => Promise; + + #getTransactions: () => TransactionMeta[]; + + #timeout: ReturnType | undefined; + + #running = false; + + /** + * Constructs a new instance of the GasFeePoller. + * @param options - The options for this instance. + * @param options.gasFeeFlows - The gas fee flows to use to obtain suitable gas fees. + * @param options.getEthQuery - Callback to obtain an EthQuery instance. + * @param options.getGasFeeControllerEstimates - Callback to obtain the default fee estimates. + * @param options.getTransactions - Callback to obtain the transaction data. + * @param options.onStateChange - Callback to register a listener for controller state changes. + */ + constructor({ + gasFeeFlows, + getEthQuery, + getGasFeeControllerEstimates, + getTransactions, + onStateChange, + }: { + gasFeeFlows: GasFeeFlow[]; + getEthQuery: (chainId: Hex, networkClientId?: NetworkClientId) => EthQuery; + getGasFeeControllerEstimates: () => Promise; + getTransactions: () => TransactionMeta[]; + onStateChange: (listener: () => void) => void; + }) { + this.#gasFeeFlows = gasFeeFlows; + this.#getEthQuery = getEthQuery; + this.#getGasFeeControllerEstimates = getGasFeeControllerEstimates; + this.#getTransactions = getTransactions; + + onStateChange(() => { + const unapprovedTransactions = this.#getUnapprovedTransactions(); + + if (unapprovedTransactions.length) { + this.#start(); + } else { + this.#stop(); + } + }); + } + + #start() { + if (this.#running) { + return; + } + + // Intentionally not awaiting since this starts the timeout chain. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#onTimeout(); + + this.#running = true; + + log('Started polling'); + } + + #stop() { + if (!this.#running) { + return; + } + + clearTimeout(this.#timeout); + + this.#timeout = undefined; + this.#running = false; + + log('Stopped polling'); + } + + async #onTimeout() { + await this.#updateUnapprovedTransactions(); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + this.#timeout = setTimeout(() => this.#onTimeout(), INTERVAL_MILLISECONDS); + } + + async #updateUnapprovedTransactions() { + const unapprovedTransactions = this.#getUnapprovedTransactions(); + + log('Found unapproved transactions', { + count: unapprovedTransactions.length, + }); + + await Promise.all( + unapprovedTransactions.map((tx) => + this.#updateTransactionSuggestedFees(tx), + ), + ); + } + + async #updateTransactionSuggestedFees(transactionMeta: TransactionMeta) { + const { chainId, networkClientId } = transactionMeta; + + const ethQuery = this.#getEthQuery(chainId, networkClientId); + const gasFeeFlow = getGasFeeFlow(transactionMeta, this.#gasFeeFlows); + + if (!gasFeeFlow) { + log('No gas fee flow found', transactionMeta.id); + } else { + log( + 'Found gas fee flow', + gasFeeFlow.constructor.name, + transactionMeta.id, + ); + } + + const request: GasFeeFlowRequest = { + ethQuery, + getGasFeeControllerEstimates: this.#getGasFeeControllerEstimates, + transactionMeta, + }; + + let gasFeeEstimates: GasFeeEstimates | undefined; + + if (gasFeeFlow) { + try { + const response = await gasFeeFlow.getGasFees(request); + gasFeeEstimates = response.estimates; + } catch (error) { + log('Failed to get suggested gas fees', transactionMeta.id, error); + } + } + + if (!gasFeeEstimates && transactionMeta.gasFeeEstimatesLoaded) { + return; + } + + transactionMeta.gasFeeEstimates = gasFeeEstimates; + transactionMeta.gasFeeEstimatesLoaded = true; + + this.hub.emit('transaction-updated', transactionMeta); + + log('Updated suggested gas fees', { + gasFeeEstimates: transactionMeta.gasFeeEstimates, + transaction: transactionMeta.id, + }); + } + + #getUnapprovedTransactions() { + return this.#getTransactions().filter( + (tx) => tx.status === TransactionStatus.unapproved, + ); + } +} diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index 274f1128ee..49b39c4eff 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -1,8 +1,7 @@ /* eslint-disable jest/prefer-spy-on */ /* eslint-disable jsdoc/require-jsdoc */ -import { NetworkType } from '@metamask/controller-utils'; -import type { BlockTracker, NetworkState } from '@metamask/network-controller'; +import type { BlockTracker } from '@metamask/network-controller'; import { TransactionStatus, @@ -19,13 +18,7 @@ jest.mock('@metamask/controller-utils', () => ({ console.error = jest.fn(); -const NETWORK_STATE_MOCK: NetworkState = { - providerConfig: { - chainId: '0x1', - type: NetworkType.mainnet, - }, -} as unknown as NetworkState; - +const CHAIN_ID_MOCK = '0x1' as const; const ADDRESS_MOCK = '0x1'; const FROM_BLOCK_HEX_MOCK = '0x20'; const FROM_BLOCK_DECIMAL_MOCK = 32; @@ -41,7 +34,7 @@ const CONTROLLER_ARGS_MOCK = { blockTracker: BLOCK_TRACKER_MOCK, getCurrentAccount: () => ADDRESS_MOCK, getLastFetchedBlockNumbers: () => ({}), - getNetworkState: () => NETWORK_STATE_MOCK, + getChainId: () => CHAIN_ID_MOCK, remoteTransactionSource: {} as RemoteTransactionSource, transactionLimit: 1, }; @@ -154,7 +147,7 @@ describe('IncomingTransactionHelper', () => { expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith({ address: ADDRESS_MOCK, - currentChainId: NETWORK_STATE_MOCK.providerConfig.chainId, + currentChainId: CHAIN_ID_MOCK, fromBlock: undefined, limit: CONTROLLER_ARGS_MOCK.transactionLimit, }); @@ -210,7 +203,7 @@ describe('IncomingTransactionHelper', () => { ...CONTROLLER_ARGS_MOCK, remoteTransactionSource, getLastFetchedBlockNumbers: () => ({ - [`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDRESS_MOCK}#${LAST_BLOCK_VARIATION_MOCK}`]: + [`${CHAIN_ID_MOCK}#${ADDRESS_MOCK}#${LAST_BLOCK_VARIATION_MOCK}`]: FROM_BLOCK_DECIMAL_MOCK, }), }); @@ -477,7 +470,7 @@ describe('IncomingTransactionHelper', () => { ); expect(lastFetchedBlockNumbers).toStrictEqual({ - [`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDRESS_MOCK}#${LAST_BLOCK_VARIATION_MOCK}`]: + [`${CHAIN_ID_MOCK}#${ADDRESS_MOCK}#${LAST_BLOCK_VARIATION_MOCK}`]: parseInt(TRANSACTION_MOCK_2.blockNumber as string, 10), }); }); @@ -535,7 +528,7 @@ describe('IncomingTransactionHelper', () => { TRANSACTION_MOCK_2, ]), getLastFetchedBlockNumbers: () => ({ - [`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDRESS_MOCK}#${LAST_BLOCK_VARIATION_MOCK}`]: + [`${CHAIN_ID_MOCK}#${ADDRESS_MOCK}#${LAST_BLOCK_VARIATION_MOCK}`]: parseInt(TRANSACTION_MOCK_2.blockNumber as string, 10), }), }); @@ -577,8 +570,10 @@ describe('IncomingTransactionHelper', () => { ); expect(lastFetchedBlockNumbers).toStrictEqual({ - [`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDRESS_MOCK}`]: - parseInt(TRANSACTION_MOCK_2.blockNumber as string, 10), + [`${CHAIN_ID_MOCK}#${ADDRESS_MOCK}`]: parseInt( + TRANSACTION_MOCK_2.blockNumber as string, + 10, + ), }); }); }); diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 331b686145..bd8b66aeaf 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -1,4 +1,4 @@ -import type { BlockTracker, NetworkState } from '@metamask/network-controller'; +import type { BlockTracker } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import EventEmitter from 'events'; @@ -15,6 +15,21 @@ const UPDATE_CHECKS: ((txMeta: TransactionMeta) => any)[] = [ (txMeta) => txMeta.txParams.gasUsed, ]; +/** + * Configuration options for the IncomingTransactionHelper + * + * @property includeTokenTransfers - Whether or not to include ERC20 token transfers. + * @property isEnabled - Whether or not incoming transaction retrieval is enabled. + * @property queryEntireHistory - Whether to initially query the entire transaction history or only recent blocks. + * @property updateTransactions - Whether to update local transactions using remote transaction data. + */ +export type IncomingTransactionOptions = { + includeTokenTransfers?: boolean; + isEnabled?: () => boolean; + queryEntireHistory?: boolean; + updateTransactions?: boolean; +}; + export class IncomingTransactionHelper { hub: EventEmitter; @@ -26,7 +41,7 @@ export class IncomingTransactionHelper { #getLocalTransactions: () => TransactionMeta[]; - #getNetworkState: () => NetworkState; + #getChainId: () => Hex; #isEnabled: () => boolean; @@ -49,7 +64,7 @@ export class IncomingTransactionHelper { getCurrentAccount, getLastFetchedBlockNumbers, getLocalTransactions, - getNetworkState, + getChainId, isEnabled, queryEntireHistory, remoteTransactionSource, @@ -60,7 +75,7 @@ export class IncomingTransactionHelper { getCurrentAccount: () => string; getLastFetchedBlockNumbers: () => Record; getLocalTransactions?: () => TransactionMeta[]; - getNetworkState: () => NetworkState; + getChainId: () => Hex; isEnabled?: () => boolean; queryEntireHistory?: boolean; remoteTransactionSource: RemoteTransactionSource; @@ -73,7 +88,7 @@ export class IncomingTransactionHelper { this.#getCurrentAccount = getCurrentAccount; this.#getLastFetchedBlockNumbers = getLastFetchedBlockNumbers; this.#getLocalTransactions = getLocalTransactions || (() => []); - this.#getNetworkState = getNetworkState; + this.#getChainId = getChainId; this.#isEnabled = isEnabled ?? (() => true); this.#isRunning = false; this.#queryEntireHistory = queryEntireHistory ?? true; @@ -128,13 +143,9 @@ export class IncomingTransactionHelper { const additionalLastFetchedKeys = this.#remoteTransactionSource.getLastBlockVariations?.() ?? []; - const fromBlock = this.#getFromBlock( - latestBlockNumber, - additionalLastFetchedKeys, - ); - + const fromBlock = this.#getFromBlock(latestBlockNumber); const address = this.#getCurrentAccount(); - const currentChainId = this.#getCurrentChainId(); + const currentChainId = this.#getChainId(); let remoteTransactions = []; @@ -152,7 +163,6 @@ export class IncomingTransactionHelper { log('Error while fetching remote transactions', error); return; } - if (!this.#updateTransactions) { remoteTransactions = remoteTransactions.filter( (tx) => tx.txParams.to?.toLowerCase() === address.toLowerCase(), @@ -187,7 +197,6 @@ export class IncomingTransactionHelper { updated: updatedTransactions, }); } - this.#updateLastFetchedBlockNumber( remoteTransactions, additionalLastFetchedKeys, @@ -232,14 +241,16 @@ export class IncomingTransactionHelper { ); } - #getFromBlock( - latestBlockNumber: number, - additionalKeys: string[], - ): number | undefined { - const lastFetchedKey = this.#getBlockNumberKey(additionalKeys); + #getLastFetchedBlockNumberDec(): number { + const additionalLastFetchedKeys = + this.#remoteTransactionSource.getLastBlockVariations?.() ?? []; + const lastFetchedKey = this.#getBlockNumberKey(additionalLastFetchedKeys); + const lastFetchedBlockNumbers = this.#getLastFetchedBlockNumbers(); + return lastFetchedBlockNumbers[lastFetchedKey]; + } - const lastFetchedBlockNumber = - this.#getLastFetchedBlockNumbers()[lastFetchedKey]; + #getFromBlock(latestBlockNumber: number): number | undefined { + const lastFetchedBlockNumber = this.#getLastFetchedBlockNumberDec(); if (lastFetchedBlockNumber) { return lastFetchedBlockNumber + 1; @@ -280,7 +291,6 @@ export class IncomingTransactionHelper { } lastFetchedBlockNumbers[lastFetchedKey] = lastFetchedBlockNumber; - this.hub.emit('updatedLastFetchedBlockNumbers', { lastFetchedBlockNumbers, blockNumber: lastFetchedBlockNumber, @@ -288,7 +298,7 @@ export class IncomingTransactionHelper { } #getBlockNumberKey(additionalKeys: string[]): string { - const currentChainId = this.#getCurrentChainId(); + const currentChainId = this.#getChainId(); const currentAccount = this.#getCurrentAccount()?.toLowerCase(); return [currentChainId, currentAccount, ...additionalKeys].join('#'); @@ -296,15 +306,11 @@ export class IncomingTransactionHelper { #canStart(): boolean { const isEnabled = this.#isEnabled(); - const currentChainId = this.#getCurrentChainId(); + const currentChainId = this.#getChainId(); const isSupportedNetwork = this.#remoteTransactionSource.isSupportedNetwork(currentChainId); return isEnabled && isSupportedNetwork; } - - #getCurrentChainId(): Hex { - return this.#getNetworkState().providerConfig.chainId; - } } diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts new file mode 100644 index 0000000000..133258eb6c --- /dev/null +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts @@ -0,0 +1,869 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { ChainId } from '@metamask/controller-utils'; +import type { NetworkClientId, Provider } from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; +import type { NonceTracker } from 'nonce-tracker'; +import { useFakeTimers } from 'sinon'; + +import { advanceTime } from '../../../../tests/helpers'; +import { EtherscanRemoteTransactionSource } from './EtherscanRemoteTransactionSource'; +import type { IncomingTransactionHelper } from './IncomingTransactionHelper'; +import { MultichainTrackingHelper } from './MultichainTrackingHelper'; +import type { PendingTransactionTracker } from './PendingTransactionTracker'; + +jest.mock( + '@metamask/eth-query', + () => + function (provider: Provider) { + return { provider }; + }, +); + +function buildMockProvider(networkClientId: NetworkClientId) { + return { + mockProvider: networkClientId, + }; +} + +function buildMockBlockTracker(networkClientId: NetworkClientId) { + return { + mockBlockTracker: networkClientId, + }; +} + +const MOCK_BLOCK_TRACKERS = { + mainnet: buildMockBlockTracker('mainnet'), + sepolia: buildMockBlockTracker('sepolia'), + goerli: buildMockBlockTracker('goerli'), + 'customNetworkClientId-1': buildMockBlockTracker('customNetworkClientId-1'), +}; + +const MOCK_PROVIDERS = { + mainnet: buildMockProvider('mainnet'), + sepolia: buildMockProvider('sepolia'), + goerli: buildMockProvider('goerli'), + 'customNetworkClientId-1': buildMockProvider('customNetworkClientId-1'), +}; + +/** + * Create a new instance of the MultichainTrackingHelper. + * + * @param opts - Options to use when creating the instance. + * @param opts.options - Any options to override the test defaults. + * @returns The new MultichainTrackingHelper instance. + */ +function newMultichainTrackingHelper( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + opts: any = {}, +) { + const mockGetNetworkClientById = jest + .fn() + .mockImplementation((networkClientId) => { + switch (networkClientId) { + case 'mainnet': + return { + configuration: { + chainId: '0x1', + }, + blockTracker: MOCK_BLOCK_TRACKERS.mainnet, + provider: MOCK_PROVIDERS.mainnet, + }; + case 'sepolia': + return { + configuration: { + chainId: ChainId.sepolia, + }, + blockTracker: MOCK_BLOCK_TRACKERS.sepolia, + provider: MOCK_PROVIDERS.sepolia, + }; + case 'goerli': + return { + configuration: { + chainId: ChainId.goerli, + }, + blockTracker: MOCK_BLOCK_TRACKERS.goerli, + provider: MOCK_PROVIDERS.goerli, + }; + case 'customNetworkClientId-1': + return { + configuration: { + chainId: '0xa', + }, + blockTracker: MOCK_BLOCK_TRACKERS['customNetworkClientId-1'], + provider: MOCK_PROVIDERS['customNetworkClientId-1'], + }; + default: + throw new Error(`Invalid network client id ${networkClientId}`); + } + }); + + const mockFindNetworkClientIdByChainId = jest + .fn() + .mockImplementation((chainId) => { + switch (chainId) { + case '0x1': + return 'mainnet'; + case ChainId.sepolia: + return 'sepolia'; + case ChainId.goerli: + return 'goerli'; + case '0xa': + return 'customNetworkClientId-1'; + default: + throw new Error("Couldn't find networkClientId for chainId"); + } + }); + + const mockGetNetworkClientRegistry = jest.fn().mockReturnValue({ + mainnet: { + configuration: { + chainId: '0x1', + }, + }, + sepolia: { + configuration: { + chainId: ChainId.sepolia, + }, + }, + goerli: { + configuration: { + chainId: ChainId.goerli, + }, + }, + 'customNetworkClientId-1': { + configuration: { + chainId: '0xa', + }, + }, + }); + + const mockNonceLock = { releaseLock: jest.fn() }; + const mockNonceTrackers: Record> = {}; + const mockCreateNonceTracker = jest + .fn() + .mockImplementation(({ chainId }: { chainId: Hex }) => { + const mockNonceTracker = { + getNonceLock: jest.fn().mockResolvedValue(mockNonceLock), + } as unknown as jest.Mocked; + mockNonceTrackers[chainId] = mockNonceTracker; + return mockNonceTracker; + }); + + const mockIncomingTransactionHelpers: Record< + Hex, + jest.Mocked + > = {}; + const mockCreateIncomingTransactionHelper = jest + .fn() + .mockImplementation(({ chainId }: { chainId: Hex }) => { + const mockIncomingTransactionHelper = { + start: jest.fn(), + stop: jest.fn(), + update: jest.fn(), + } as unknown as jest.Mocked; + mockIncomingTransactionHelpers[chainId] = mockIncomingTransactionHelper; + return mockIncomingTransactionHelper; + }); + + const mockPendingTransactionTrackers: Record< + Hex, + jest.Mocked + > = {}; + const mockCreatePendingTransactionTracker = jest + .fn() + .mockImplementation(({ chainId }: { chainId: Hex }) => { + const mockPendingTransactionTracker = { + start: jest.fn(), + stop: jest.fn(), + } as unknown as jest.Mocked; + mockPendingTransactionTrackers[chainId] = mockPendingTransactionTracker; + return mockPendingTransactionTracker; + }); + + const options = { + isMultichainEnabled: true, + provider: MOCK_PROVIDERS.mainnet, + nonceTracker: { + getNonceLock: jest.fn().mockResolvedValue(mockNonceLock), + }, + incomingTransactionOptions: { + // make this a comparable reference + includeTokenTransfers: true, + isEnabled: () => true, + queryEntireHistory: true, + updateTransactions: true, + }, + findNetworkClientIdByChainId: mockFindNetworkClientIdByChainId, + getNetworkClientById: mockGetNetworkClientById, + getNetworkClientRegistry: mockGetNetworkClientRegistry, + removeIncomingTransactionHelperListeners: jest.fn(), + removePendingTransactionTrackerListeners: jest.fn(), + createNonceTracker: mockCreateNonceTracker, + createIncomingTransactionHelper: mockCreateIncomingTransactionHelper, + createPendingTransactionTracker: mockCreatePendingTransactionTracker, + onNetworkStateChange: jest.fn(), + ...opts, + }; + + const helper = new MultichainTrackingHelper(options); + + return { + helper, + options, + mockNonceLock, + mockNonceTrackers, + mockIncomingTransactionHelpers, + mockPendingTransactionTrackers, + }; +} + +describe('MultichainTrackingHelper', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('onNetworkStateChange', () => { + it('refreshes the tracking map', () => { + const { options, helper } = newMultichainTrackingHelper(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (options.onNetworkStateChange as any).mock.calls[0][0]({}, []); + + expect(options.getNetworkClientRegistry).toHaveBeenCalledTimes(1); + expect(helper.has('mainnet')).toBe(true); + expect(helper.has('goerli')).toBe(true); + expect(helper.has('sepolia')).toBe(true); + expect(helper.has('customNetworkClientId-1')).toBe(true); + }); + + it('refreshes the tracking map and excludes removed networkClientIds in the patches', () => { + const { options, helper } = newMultichainTrackingHelper(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (options.onNetworkStateChange as any).mock.calls[0][0]({}, [ + { + op: 'remove', + path: ['networkConfigurations', 'mainnet'], + value: 'foo', + }, + ]); + + expect(options.getNetworkClientRegistry).toHaveBeenCalledTimes(1); + expect(helper.has('mainnet')).toBe(false); + expect(helper.has('goerli')).toBe(true); + expect(helper.has('sepolia')).toBe(true); + expect(helper.has('customNetworkClientId-1')).toBe(true); + }); + + it('does not refresh the tracking map when isMultichainEnabled: false', () => { + const { options, helper } = newMultichainTrackingHelper({ + isMultichainEnabled: false, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (options.onNetworkStateChange as any).mock.calls[0][0]({}, []); + + expect(options.getNetworkClientRegistry).not.toHaveBeenCalled(); + expect(helper.has('mainnet')).toBe(false); + expect(helper.has('goerli')).toBe(false); + expect(helper.has('sepolia')).toBe(false); + expect(helper.has('customNetworkClientId-1')).toBe(false); + }); + }); + + describe('initialize', () => { + it('initializes the tracking map', () => { + const { options, helper } = newMultichainTrackingHelper(); + + helper.initialize(); + + expect(options.getNetworkClientRegistry).toHaveBeenCalledTimes(1); + expect(helper.has('mainnet')).toBe(true); + expect(helper.has('goerli')).toBe(true); + expect(helper.has('sepolia')).toBe(true); + expect(helper.has('customNetworkClientId-1')).toBe(true); + }); + + it('does not initialize the tracking map when isMultichainEnabled: false', () => { + const { options, helper } = newMultichainTrackingHelper({ + isMultichainEnabled: false, + }); + + helper.initialize(); + + expect(options.getNetworkClientRegistry).not.toHaveBeenCalled(); + expect(helper.has('mainnet')).toBe(false); + expect(helper.has('goerli')).toBe(false); + expect(helper.has('sepolia')).toBe(false); + expect(helper.has('customNetworkClientId-1')).toBe(false); + }); + }); + + describe('stopAllTracking', () => { + it('clears the tracking map', () => { + const { helper } = newMultichainTrackingHelper(); + + helper.initialize(); + + expect(helper.has('mainnet')).toBe(true); + expect(helper.has('goerli')).toBe(true); + expect(helper.has('sepolia')).toBe(true); + expect(helper.has('customNetworkClientId-1')).toBe(true); + + helper.stopAllTracking(); + + expect(helper.has('mainnet')).toBe(false); + expect(helper.has('goerli')).toBe(false); + expect(helper.has('sepolia')).toBe(false); + expect(helper.has('customNetworkClientId-1')).toBe(false); + }); + }); + + describe('#startTrackingByNetworkClientId', () => { + it('instantiates trackers and adds them to the tracking map', () => { + const { options, helper } = newMultichainTrackingHelper({ + getNetworkClientRegistry: jest.fn().mockReturnValue({ + mainnet: { + configuration: { + chainId: '0x1', + }, + }, + }), + }); + + helper.initialize(); + + expect(options.createNonceTracker).toHaveBeenCalledTimes(1); + expect(options.createNonceTracker).toHaveBeenCalledWith({ + provider: MOCK_PROVIDERS.mainnet, + blockTracker: MOCK_BLOCK_TRACKERS.mainnet, + chainId: '0x1', + }); + + expect(options.createIncomingTransactionHelper).toHaveBeenCalledTimes(1); + expect(options.createIncomingTransactionHelper).toHaveBeenCalledWith({ + blockTracker: MOCK_BLOCK_TRACKERS.mainnet, + etherscanRemoteTransactionSource: expect.any( + EtherscanRemoteTransactionSource, + ), + chainId: '0x1', + }); + + expect(options.createPendingTransactionTracker).toHaveBeenCalledTimes(1); + expect(options.createPendingTransactionTracker).toHaveBeenCalledWith({ + provider: MOCK_PROVIDERS.mainnet, + blockTracker: MOCK_BLOCK_TRACKERS.mainnet, + chainId: '0x1', + }); + + expect(helper.has('mainnet')).toBe(true); + }); + }); + + describe('#stopTrackingByNetworkClientId', () => { + it('stops trackers and removes them from the tracking map', () => { + const { + options, + mockIncomingTransactionHelpers, + mockPendingTransactionTrackers, + helper, + } = newMultichainTrackingHelper({ + getNetworkClientRegistry: jest.fn().mockReturnValue({ + mainnet: { + configuration: { + chainId: '0x1', + }, + }, + }), + }); + + helper.initialize(); + + expect(helper.has('mainnet')).toBe(true); + + helper.stopAllTracking(); + + expect(mockPendingTransactionTrackers['0x1'].stop).toHaveBeenCalled(); + expect( + options.removePendingTransactionTrackerListeners, + ).toHaveBeenCalledWith(mockPendingTransactionTrackers['0x1']); + expect(mockIncomingTransactionHelpers['0x1'].stop).toHaveBeenCalled(); + expect( + options.removeIncomingTransactionHelperListeners, + ).toHaveBeenCalledWith(mockIncomingTransactionHelpers['0x1']); + expect(helper.has('mainnet')).toBe(false); + }); + }); + + describe('startIncomingTransactionPolling', () => { + it('starts polling on the IncomingTransactionHelper for the networkClientIds', () => { + const { mockIncomingTransactionHelpers, helper } = + newMultichainTrackingHelper(); + + helper.initialize(); + + helper.startIncomingTransactionPolling(['mainnet', 'goerli']); + expect(mockIncomingTransactionHelpers['0x1'].start).toHaveBeenCalled(); + expect( + mockIncomingTransactionHelpers[ChainId.goerli].start, + ).toHaveBeenCalled(); + expect( + mockIncomingTransactionHelpers[ChainId.sepolia].start, + ).not.toHaveBeenCalled(); + expect( + mockIncomingTransactionHelpers['0xa'].start, + ).not.toHaveBeenCalled(); + }); + }); + + describe('stopIncomingTransactionPolling', () => { + it('stops polling on the IncomingTransactionHelper for the networkClientIds', () => { + const { mockIncomingTransactionHelpers, helper } = + newMultichainTrackingHelper(); + + helper.initialize(); + + helper.stopIncomingTransactionPolling(['mainnet', 'goerli']); + expect(mockIncomingTransactionHelpers['0x1'].stop).toHaveBeenCalled(); + expect( + mockIncomingTransactionHelpers[ChainId.goerli].stop, + ).toHaveBeenCalled(); + expect( + mockIncomingTransactionHelpers[ChainId.sepolia].stop, + ).not.toHaveBeenCalled(); + expect(mockIncomingTransactionHelpers['0xa'].stop).not.toHaveBeenCalled(); + }); + }); + + describe('stopAllIncomingTransactionPolling', () => { + it('stops polling on all IncomingTransactionHelpers', () => { + const { mockIncomingTransactionHelpers, helper } = + newMultichainTrackingHelper(); + + helper.initialize(); + + helper.stopAllIncomingTransactionPolling(); + expect(mockIncomingTransactionHelpers['0x1'].stop).toHaveBeenCalled(); + expect( + mockIncomingTransactionHelpers[ChainId.goerli].stop, + ).toHaveBeenCalled(); + expect( + mockIncomingTransactionHelpers[ChainId.sepolia].stop, + ).toHaveBeenCalled(); + expect(mockIncomingTransactionHelpers['0xa'].stop).toHaveBeenCalled(); + }); + }); + + describe('updateIncomingTransactions', () => { + it('calls update on the IncomingTransactionHelper for the networkClientIds', () => { + const { mockIncomingTransactionHelpers, helper } = + newMultichainTrackingHelper(); + + helper.initialize(); + + helper.updateIncomingTransactions(['mainnet', 'goerli']); + expect(mockIncomingTransactionHelpers['0x1'].update).toHaveBeenCalled(); + expect( + mockIncomingTransactionHelpers[ChainId.goerli].update, + ).toHaveBeenCalled(); + expect( + mockIncomingTransactionHelpers[ChainId.sepolia].update, + ).not.toHaveBeenCalled(); + expect( + mockIncomingTransactionHelpers['0xa'].update, + ).not.toHaveBeenCalled(); + }); + }); + + describe('getNonceLock', () => { + describe('when given a networkClientId', () => { + it('gets the shared nonce lock by chainId for the networkClientId', async () => { + const { helper } = newMultichainTrackingHelper(); + + const mockAcquireNonceLockForChainIdKey = jest + .spyOn(helper, 'acquireNonceLockForChainIdKey') + .mockResolvedValue(jest.fn()); + + helper.initialize(); + + await helper.getNonceLock('0xdeadbeef', 'mainnet'); + + expect(mockAcquireNonceLockForChainIdKey).toHaveBeenCalledWith({ + chainId: '0x1', + key: '0xdeadbeef', + }); + }); + + it('gets the nonce lock from the NonceTracker for the networkClientId', async () => { + const { mockNonceTrackers, helper } = newMultichainTrackingHelper({}); + + jest + .spyOn(helper, 'acquireNonceLockForChainIdKey') + .mockResolvedValue(jest.fn()); + + helper.initialize(); + + await helper.getNonceLock('0xdeadbeef', 'mainnet'); + + expect(mockNonceTrackers['0x1'].getNonceLock).toHaveBeenCalledWith( + '0xdeadbeef', + ); + }); + + it('merges the nonce lock by chainId release with the NonceTracker releaseLock function', async () => { + const { mockNonceLock, helper } = newMultichainTrackingHelper({}); + + const releaseLockForChainIdKey = jest.fn(); + jest + .spyOn(helper, 'acquireNonceLockForChainIdKey') + .mockResolvedValue(releaseLockForChainIdKey); + + helper.initialize(); + + const nonceLock = await helper.getNonceLock('0xdeadbeef', 'mainnet'); + + expect(releaseLockForChainIdKey).not.toHaveBeenCalled(); + expect(mockNonceLock.releaseLock).not.toHaveBeenCalled(); + + nonceLock.releaseLock(); + + expect(releaseLockForChainIdKey).toHaveBeenCalled(); + expect(mockNonceLock.releaseLock).toHaveBeenCalled(); + }); + + it('throws an error if the networkClientId does not exist in the tracking map', async () => { + const { helper } = newMultichainTrackingHelper(); + + jest + .spyOn(helper, 'acquireNonceLockForChainIdKey') + .mockResolvedValue(jest.fn()); + + await expect( + helper.getNonceLock('0xdeadbeef', 'mainnet'), + ).rejects.toThrow('missing nonceTracker for networkClientId'); + }); + + it('throws an error and releases nonce lock by chainId if unable to acquire nonce lock from the NonceTracker', async () => { + const { mockNonceTrackers, helper } = newMultichainTrackingHelper({}); + + const releaseLockForChainIdKey = jest.fn(); + jest + .spyOn(helper, 'acquireNonceLockForChainIdKey') + .mockResolvedValue(releaseLockForChainIdKey); + + helper.initialize(); + + mockNonceTrackers['0x1'].getNonceLock.mockRejectedValue( + 'failed to acquire lock from nonceTracker', + ); + + // for some reason jest expect().rejects.toThrow doesn't work here + let error = ''; + try { + await helper.getNonceLock('0xdeadbeef', 'mainnet'); + } catch (err: unknown) { + error = err as string; + } + expect(error).toBe('failed to acquire lock from nonceTracker'); + expect(releaseLockForChainIdKey).toHaveBeenCalled(); + }); + }); + + describe('when no networkClientId given', () => { + it('does not get the shared nonce lock by chainId', async () => { + const { helper } = newMultichainTrackingHelper(); + + const mockAcquireNonceLockForChainIdKey = jest + .spyOn(helper, 'acquireNonceLockForChainIdKey') + .mockResolvedValue(jest.fn()); + + helper.initialize(); + + await helper.getNonceLock('0xdeadbeef'); + + expect(mockAcquireNonceLockForChainIdKey).not.toHaveBeenCalled(); + }); + + it('gets the nonce lock from the global NonceTracker', async () => { + const { options, helper } = newMultichainTrackingHelper({}); + + helper.initialize(); + + await helper.getNonceLock('0xdeadbeef'); + + expect(options.nonceTracker.getNonceLock).toHaveBeenCalledWith( + '0xdeadbeef', + ); + }); + + it('throws an error if unable to acquire nonce lock from the global NonceTracker', async () => { + const { options, helper } = newMultichainTrackingHelper({}); + + helper.initialize(); + + options.nonceTracker.getNonceLock.mockRejectedValue( + 'failed to acquire lock from nonceTracker', + ); + + // for some reason jest expect().rejects.toThrow doesn't work here + let error = ''; + try { + await helper.getNonceLock('0xdeadbeef'); + } catch (err: unknown) { + error = err as string; + } + expect(error).toBe('failed to acquire lock from nonceTracker'); + }); + }); + + describe('when passed a networkClientId and isMultichainEnabled: false', () => { + it('does not get the shared nonce lock by chainId', async () => { + const { helper } = newMultichainTrackingHelper({ + isMultichainEnabled: false, + }); + + const mockAcquireNonceLockForChainIdKey = jest + .spyOn(helper, 'acquireNonceLockForChainIdKey') + .mockResolvedValue(jest.fn()); + + helper.initialize(); + + await helper.getNonceLock('0xdeadbeef', '0xabc'); + + expect(mockAcquireNonceLockForChainIdKey).not.toHaveBeenCalled(); + }); + + it('gets the nonce lock from the global NonceTracker', async () => { + const { options, helper } = newMultichainTrackingHelper({ + isMultichainEnabled: false, + }); + + helper.initialize(); + + await helper.getNonceLock('0xdeadbeef', '0xabc'); + + expect(options.nonceTracker.getNonceLock).toHaveBeenCalledWith( + '0xdeadbeef', + ); + }); + + it('throws an error if unable to acquire nonce lock from the global NonceTracker', async () => { + const { options, helper } = newMultichainTrackingHelper({ + isMultichainEnabled: false, + }); + + helper.initialize(); + + options.nonceTracker.getNonceLock.mockRejectedValue( + 'failed to acquire lock from nonceTracker', + ); + + // for some reason jest expect().rejects.toThrow doesn't work here + let error = ''; + try { + await helper.getNonceLock('0xdeadbeef', '0xabc'); + } catch (err: unknown) { + error = err as string; + } + expect(error).toBe('failed to acquire lock from nonceTracker'); + }); + }); + }); + + describe('acquireNonceLockForChainIdKey', () => { + it('returns a unqiue mutex for each chainId and key combination', async () => { + const { helper } = newMultichainTrackingHelper(); + + await helper.acquireNonceLockForChainIdKey({ chainId: '0x1', key: 'a' }); + await helper.acquireNonceLockForChainIdKey({ chainId: '0x1', key: 'b' }); + await helper.acquireNonceLockForChainIdKey({ chainId: '0xa', key: 'a' }); + await helper.acquireNonceLockForChainIdKey({ chainId: '0xa', key: 'b' }); + + // nothing to exepect as this spec will pass if all locks are acquired + }); + + it('should block on attempts to get the lock for the same chainId and key combination', async () => { + const clock = useFakeTimers(); + const { helper } = newMultichainTrackingHelper(); + + const firstReleaseLockPromise = helper.acquireNonceLockForChainIdKey({ + chainId: '0x1', + key: 'a', + }); + + const firstReleaseLock = await firstReleaseLockPromise; + + const secondReleaseLockPromise = helper.acquireNonceLockForChainIdKey({ + chainId: '0x1', + key: 'a', + }); + + const delay = () => + new Promise(async (resolve) => { + await advanceTime({ clock, duration: 100 }); + resolve(null); + }); + + let secondReleaseLockIfAcquired = await Promise.race([ + secondReleaseLockPromise, + delay(), + ]); + expect(secondReleaseLockIfAcquired).toBeNull(); + + await firstReleaseLock(); + await advanceTime({ clock, duration: 1 }); + + secondReleaseLockIfAcquired = await Promise.race([ + secondReleaseLockPromise, + delay(), + ]); + + expect(secondReleaseLockIfAcquired).toStrictEqual(expect.any(Function)); + + clock.restore(); + }); + }); + + describe('getEthQuery', () => { + describe('when given networkClientId and chainId', () => { + it('returns EthQuery with the networkClientId provider when available', () => { + const { options, helper } = newMultichainTrackingHelper(); + + const ethQuery = helper.getEthQuery({ + networkClientId: 'goerli', + chainId: '0xa', + }); + expect(ethQuery.provider).toBe(MOCK_PROVIDERS.goerli); + + expect(options.getNetworkClientById).toHaveBeenCalledTimes(1); + expect(options.getNetworkClientById).toHaveBeenCalledWith('goerli'); + }); + + it('returns EthQuery with a fallback networkClient provider matching the chainId when available', () => { + const { options, helper } = newMultichainTrackingHelper(); + + const ethQuery = helper.getEthQuery({ + networkClientId: 'missingNetworkClientId', + chainId: '0xa', + }); + expect(ethQuery.provider).toBe( + MOCK_PROVIDERS['customNetworkClientId-1'], + ); + + expect(options.getNetworkClientById).toHaveBeenCalledTimes(2); + expect(options.getNetworkClientById).toHaveBeenCalledWith( + 'missingNetworkClientId', + ); + expect(options.findNetworkClientIdByChainId).toHaveBeenCalledWith( + '0xa', + ); + expect(options.getNetworkClientById).toHaveBeenCalledWith( + 'customNetworkClientId-1', + ); + }); + + it('returns EthQuery with the fallback global provider if networkClientId and chainId cannot be satisfied', () => { + const { options, helper } = newMultichainTrackingHelper(); + + const ethQuery = helper.getEthQuery({ + networkClientId: 'missingNetworkClientId', + chainId: '0xdeadbeef', + }); + expect(ethQuery.provider).toBe(MOCK_PROVIDERS.mainnet); + + expect(options.getNetworkClientById).toHaveBeenCalledTimes(1); + expect(options.getNetworkClientById).toHaveBeenCalledWith( + 'missingNetworkClientId', + ); + expect(options.findNetworkClientIdByChainId).toHaveBeenCalledWith( + '0xdeadbeef', + ); + }); + }); + + describe('when given only networkClientId', () => { + it('returns EthQuery with the networkClientId provider when available', () => { + const { options, helper } = newMultichainTrackingHelper(); + + const ethQuery = helper.getEthQuery({ networkClientId: 'goerli' }); + expect(ethQuery.provider).toBe(MOCK_PROVIDERS.goerli); + + expect(options.getNetworkClientById).toHaveBeenCalledTimes(1); + expect(options.getNetworkClientById).toHaveBeenCalledWith('goerli'); + }); + + it('returns EthQuery with the fallback global provider if networkClientId cannot be satisfied', () => { + const { options, helper } = newMultichainTrackingHelper(); + + const ethQuery = helper.getEthQuery({ + networkClientId: 'missingNetworkClientId', + }); + expect(ethQuery.provider).toBe(MOCK_PROVIDERS.mainnet); + + expect(options.getNetworkClientById).toHaveBeenCalledTimes(1); + expect(options.getNetworkClientById).toHaveBeenCalledWith( + 'missingNetworkClientId', + ); + }); + }); + + describe('when given only chainId', () => { + it('returns EthQuery with a fallback networkClient provider matching the chainId when available', () => { + const { options, helper } = newMultichainTrackingHelper(); + + const ethQuery = helper.getEthQuery({ chainId: '0xa' }); + expect(ethQuery.provider).toBe( + MOCK_PROVIDERS['customNetworkClientId-1'], + ); + + expect(options.getNetworkClientById).toHaveBeenCalledTimes(1); + expect(options.findNetworkClientIdByChainId).toHaveBeenCalledWith( + '0xa', + ); + expect(options.getNetworkClientById).toHaveBeenCalledWith( + 'customNetworkClientId-1', + ); + }); + + it('returns EthQuery with the fallback global provider if chainId cannot be satisfied', () => { + const { options, helper } = newMultichainTrackingHelper(); + + const ethQuery = helper.getEthQuery({ chainId: '0xdeadbeef' }); + expect(ethQuery.provider).toBe(MOCK_PROVIDERS.mainnet); + + expect(options.findNetworkClientIdByChainId).toHaveBeenCalledWith( + '0xdeadbeef', + ); + }); + }); + + it('returns EthQuery with the global provider when no arguments are provided', () => { + const { options, helper } = newMultichainTrackingHelper(); + + const ethQuery = helper.getEthQuery(); + expect(ethQuery.provider).toBe(MOCK_PROVIDERS.mainnet); + + expect(options.getNetworkClientById).not.toHaveBeenCalled(); + }); + + it('always returns EthQuery with the global provider when isMultichainEnabled: false', () => { + const { options, helper } = newMultichainTrackingHelper({ + isMultichainEnabled: false, + }); + + let ethQuery = helper.getEthQuery({ + networkClientId: 'goerli', + chainId: '0x5', + }); + expect(ethQuery.provider).toBe(MOCK_PROVIDERS.mainnet); + ethQuery = helper.getEthQuery({ networkClientId: 'goerli' }); + expect(ethQuery.provider).toBe(MOCK_PROVIDERS.mainnet); + ethQuery = helper.getEthQuery({ chainId: '0x5' }); + expect(ethQuery.provider).toBe(MOCK_PROVIDERS.mainnet); + ethQuery = helper.getEthQuery(); + expect(ethQuery.provider).toBe(MOCK_PROVIDERS.mainnet); + + expect(options.getNetworkClientById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts new file mode 100644 index 0000000000..3af5c2c09a --- /dev/null +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts @@ -0,0 +1,454 @@ +import EthQuery from '@metamask/eth-query'; +import type { + NetworkClientId, + NetworkController, + NetworkClient, + BlockTracker, + Provider, + NetworkControllerStateChangeEvent, +} from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; +import { Mutex } from 'async-mutex'; +import type { NonceLock, NonceTracker } from 'nonce-tracker'; + +import { incomingTransactionsLogger as log } from '../logger'; +import { EtherscanRemoteTransactionSource } from './EtherscanRemoteTransactionSource'; +import type { + IncomingTransactionHelper, + IncomingTransactionOptions, +} from './IncomingTransactionHelper'; +import type { PendingTransactionTracker } from './PendingTransactionTracker'; + +/** + * Registry of network clients provided by the NetworkController + */ +type NetworkClientRegistry = ReturnType< + NetworkController['getNetworkClientRegistry'] +>; + +export type MultichainTrackingHelperOptions = { + isMultichainEnabled: boolean; + provider: Provider; + nonceTracker: NonceTracker; + incomingTransactionOptions: IncomingTransactionOptions; + + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + getNetworkClientById: NetworkController['getNetworkClientById']; + getNetworkClientRegistry: NetworkController['getNetworkClientRegistry']; + + removeIncomingTransactionHelperListeners: ( + IncomingTransactionHelper: IncomingTransactionHelper, + ) => void; + removePendingTransactionTrackerListeners: ( + pendingTransactionTracker: PendingTransactionTracker, + ) => void; + createNonceTracker: (opts: { + provider: Provider; + blockTracker: BlockTracker; + chainId?: Hex; + }) => NonceTracker; + createIncomingTransactionHelper: (opts: { + blockTracker: BlockTracker; + etherscanRemoteTransactionSource: EtherscanRemoteTransactionSource; + chainId?: Hex; + }) => IncomingTransactionHelper; + createPendingTransactionTracker: (opts: { + provider: Provider; + blockTracker: BlockTracker; + chainId?: Hex; + }) => PendingTransactionTracker; + onNetworkStateChange: ( + listener: ( + ...payload: NetworkControllerStateChangeEvent['payload'] + ) => void, + ) => void; +}; + +export class MultichainTrackingHelper { + #isMultichainEnabled: boolean; + + readonly #provider: Provider; + + readonly #nonceTracker: NonceTracker; + + readonly #incomingTransactionOptions: IncomingTransactionOptions; + + readonly #findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + + readonly #getNetworkClientById: NetworkController['getNetworkClientById']; + + readonly #getNetworkClientRegistry: NetworkController['getNetworkClientRegistry']; + + readonly #removeIncomingTransactionHelperListeners: ( + IncomingTransactionHelper: IncomingTransactionHelper, + ) => void; + + readonly #removePendingTransactionTrackerListeners: ( + pendingTransactionTracker: PendingTransactionTracker, + ) => void; + + readonly #createNonceTracker: (opts: { + provider: Provider; + blockTracker: BlockTracker; + chainId?: Hex; + }) => NonceTracker; + + readonly #createIncomingTransactionHelper: (opts: { + blockTracker: BlockTracker; + chainId?: Hex; + etherscanRemoteTransactionSource: EtherscanRemoteTransactionSource; + }) => IncomingTransactionHelper; + + readonly #createPendingTransactionTracker: (opts: { + provider: Provider; + blockTracker: BlockTracker; + chainId?: Hex; + }) => PendingTransactionTracker; + + readonly #nonceMutexesByChainId = new Map>(); + + readonly #trackingMap: Map< + NetworkClientId, + { + nonceTracker: NonceTracker; + pendingTransactionTracker: PendingTransactionTracker; + incomingTransactionHelper: IncomingTransactionHelper; + } + > = new Map(); + + readonly #etherscanRemoteTransactionSourcesMap: Map< + Hex, + EtherscanRemoteTransactionSource + > = new Map(); + + constructor({ + isMultichainEnabled, + provider, + nonceTracker, + incomingTransactionOptions, + findNetworkClientIdByChainId, + getNetworkClientById, + getNetworkClientRegistry, + removeIncomingTransactionHelperListeners, + removePendingTransactionTrackerListeners, + createNonceTracker, + createIncomingTransactionHelper, + createPendingTransactionTracker, + onNetworkStateChange, + }: MultichainTrackingHelperOptions) { + this.#isMultichainEnabled = isMultichainEnabled; + this.#provider = provider; + this.#nonceTracker = nonceTracker; + this.#incomingTransactionOptions = incomingTransactionOptions; + + this.#findNetworkClientIdByChainId = findNetworkClientIdByChainId; + this.#getNetworkClientById = getNetworkClientById; + this.#getNetworkClientRegistry = getNetworkClientRegistry; + + this.#removeIncomingTransactionHelperListeners = + removeIncomingTransactionHelperListeners; + this.#removePendingTransactionTrackerListeners = + removePendingTransactionTrackerListeners; + this.#createNonceTracker = createNonceTracker; + this.#createIncomingTransactionHelper = createIncomingTransactionHelper; + this.#createPendingTransactionTracker = createPendingTransactionTracker; + + onNetworkStateChange((_, patches) => { + if (this.#isMultichainEnabled) { + const networkClients = this.#getNetworkClientRegistry(); + patches.forEach(({ op, path }) => { + if (op === 'remove' && path[0] === 'networkConfigurations') { + const networkClientId = path[1] as NetworkClientId; + delete networkClients[networkClientId]; + } + }); + + this.#refreshTrackingMap(networkClients); + } + }); + } + + initialize() { + if (!this.#isMultichainEnabled) { + return; + } + const networkClients = this.#getNetworkClientRegistry(); + this.#refreshTrackingMap(networkClients); + } + + has(networkClientId: NetworkClientId) { + return this.#trackingMap.has(networkClientId); + } + + getEthQuery({ + networkClientId, + chainId, + }: { + networkClientId?: NetworkClientId; + chainId?: Hex; + } = {}): EthQuery { + if (!this.#isMultichainEnabled) { + return new EthQuery(this.#provider); + } + let networkClient: NetworkClient | undefined; + + if (networkClientId) { + try { + networkClient = this.#getNetworkClientById(networkClientId); + } catch (err) { + log('failed to get network client by networkClientId'); + } + } + if (!networkClient && chainId) { + try { + networkClientId = this.#findNetworkClientIdByChainId(chainId); + networkClient = this.#getNetworkClientById(networkClientId); + } catch (err) { + log('failed to get network client by chainId'); + } + } + + if (networkClient) { + return new EthQuery(networkClient.provider); + } + + // NOTE(JL): we're not ready to drop globally selected ethQuery yet. + // Some calls to getEthQuery only have access to optional networkClientId + // throw new Error('failed to get eth query instance'); + return new EthQuery(this.#provider); + } + + /** + * Gets the mutex intended to guard the nonceTracker for a particular chainId and key . + * + * @param opts - The options object. + * @param opts.chainId - The hex chainId. + * @param opts.key - The hex address (or constant) pertaining to the chainId + * @returns Mutex instance for the given chainId and key pair + */ + async acquireNonceLockForChainIdKey({ + chainId, + key = 'global', + }: { + chainId: Hex; + key?: string; + }): Promise<() => void> { + let nonceMutexesForChainId = this.#nonceMutexesByChainId.get(chainId); + if (!nonceMutexesForChainId) { + nonceMutexesForChainId = new Map(); + this.#nonceMutexesByChainId.set(chainId, nonceMutexesForChainId); + } + let nonceMutexForKey = nonceMutexesForChainId.get(key); + if (!nonceMutexForKey) { + nonceMutexForKey = new Mutex(); + nonceMutexesForChainId.set(key, nonceMutexForKey); + } + + return await nonceMutexForKey.acquire(); + } + + /** + * Gets the next nonce according to the nonce-tracker. + * Ensure `releaseLock` is called once processing of the `nonce` value is complete. + * + * @param address - The hex string address for the transaction. + * @param networkClientId - The network client ID for the transaction, used to fetch the correct nonce tracker. + * @returns object with the `nextNonce` `nonceDetails`, and the releaseLock. + */ + async getNonceLock( + address: string, + networkClientId?: NetworkClientId, + ): Promise { + let releaseLockForChainIdKey: (() => void) | undefined; + let nonceTracker = this.#nonceTracker; + if (networkClientId && this.#isMultichainEnabled) { + const networkClient = this.#getNetworkClientById(networkClientId); + releaseLockForChainIdKey = await this.acquireNonceLockForChainIdKey({ + chainId: networkClient.configuration.chainId, + key: address, + }); + const trackers = this.#trackingMap.get(networkClientId); + if (!trackers) { + throw new Error('missing nonceTracker for networkClientId'); + } + nonceTracker = trackers.nonceTracker; + } + + // Acquires the lock for the chainId + address and the nonceLock from the nonceTracker, then + // couples them together by replacing the nonceLock's releaseLock method with + // an anonymous function that calls releases both the original nonceLock and the + // lock for the chainId. + try { + const nonceLock = await nonceTracker.getNonceLock(address); + return { + ...nonceLock, + releaseLock: () => { + nonceLock.releaseLock(); + releaseLockForChainIdKey?.(); + }, + }; + } catch (err) { + releaseLockForChainIdKey?.(); + throw err; + } + } + + startIncomingTransactionPolling(networkClientIds: NetworkClientId[] = []) { + networkClientIds.forEach((networkClientId) => { + this.#trackingMap.get(networkClientId)?.incomingTransactionHelper.start(); + }); + } + + stopIncomingTransactionPolling(networkClientIds: NetworkClientId[] = []) { + networkClientIds.forEach((networkClientId) => { + this.#trackingMap.get(networkClientId)?.incomingTransactionHelper.stop(); + }); + } + + stopAllIncomingTransactionPolling() { + for (const [, trackers] of this.#trackingMap) { + trackers.incomingTransactionHelper.stop(); + } + } + + async updateIncomingTransactions(networkClientIds: NetworkClientId[] = []) { + const promises = await Promise.allSettled( + networkClientIds.map(async (networkClientId) => { + return await this.#trackingMap + .get(networkClientId) + ?.incomingTransactionHelper.update(); + }), + ); + + promises + .filter((result) => result.status === 'rejected') + .forEach((result) => { + log( + 'failed to update incoming transactions', + (result as PromiseRejectedResult).reason, + ); + }); + } + + checkForPendingTransactionAndStartPolling = () => { + for (const [, trackers] of this.#trackingMap) { + trackers.pendingTransactionTracker.startIfPendingTransactions(); + } + }; + + stopAllTracking() { + for (const [networkClientId] of this.#trackingMap) { + this.#stopTrackingByNetworkClientId(networkClientId); + } + } + + #refreshTrackingMap = (networkClients: NetworkClientRegistry) => { + this.#refreshEtherscanRemoteTransactionSources(networkClients); + + const networkClientIds = Object.keys(networkClients); + const existingNetworkClientIds = Array.from(this.#trackingMap.keys()); + + // Remove tracking for NetworkClientIds that no longer exist + const networkClientIdsToRemove = existingNetworkClientIds.filter( + (id) => !networkClientIds.includes(id), + ); + networkClientIdsToRemove.forEach((id) => { + this.#stopTrackingByNetworkClientId(id); + }); + + // Start tracking new NetworkClientIds from the registry + const networkClientIdsToAdd = networkClientIds.filter( + (id) => !existingNetworkClientIds.includes(id), + ); + networkClientIdsToAdd.forEach((id) => { + this.#startTrackingByNetworkClientId(id); + }); + }; + + #stopTrackingByNetworkClientId(networkClientId: NetworkClientId) { + const trackers = this.#trackingMap.get(networkClientId); + if (trackers) { + trackers.pendingTransactionTracker.stop(); + this.#removePendingTransactionTrackerListeners( + trackers.pendingTransactionTracker, + ); + trackers.incomingTransactionHelper.stop(); + this.#removeIncomingTransactionHelperListeners( + trackers.incomingTransactionHelper, + ); + this.#trackingMap.delete(networkClientId); + } + } + + #startTrackingByNetworkClientId(networkClientId: NetworkClientId) { + const trackers = this.#trackingMap.get(networkClientId); + if (trackers) { + return; + } + + const { + provider, + blockTracker, + configuration: { chainId }, + } = this.#getNetworkClientById(networkClientId); + + let etherscanRemoteTransactionSource = + this.#etherscanRemoteTransactionSourcesMap.get(chainId); + if (!etherscanRemoteTransactionSource) { + etherscanRemoteTransactionSource = new EtherscanRemoteTransactionSource({ + includeTokenTransfers: + this.#incomingTransactionOptions.includeTokenTransfers, + }); + this.#etherscanRemoteTransactionSourcesMap.set( + chainId, + etherscanRemoteTransactionSource, + ); + } + + const nonceTracker = this.#createNonceTracker({ + provider, + blockTracker, + chainId, + }); + + const incomingTransactionHelper = this.#createIncomingTransactionHelper({ + blockTracker, + etherscanRemoteTransactionSource, + chainId, + }); + + const pendingTransactionTracker = this.#createPendingTransactionTracker({ + provider, + blockTracker, + chainId, + }); + + this.#trackingMap.set(networkClientId, { + nonceTracker, + incomingTransactionHelper, + pendingTransactionTracker, + }); + } + + #refreshEtherscanRemoteTransactionSources = ( + networkClients: NetworkClientRegistry, + ) => { + // this will be prettier when we have consolidated network clients with a single chainId: + // check if there are still other network clients using the same chainId + // if not remove the etherscanRemoteTransaction source from the map + const chainIdsInRegistry = new Set(); + Object.values(networkClients).forEach((networkClient) => + chainIdsInRegistry.add(networkClient.configuration.chainId), + ); + const existingChainIds = Array.from( + this.#etherscanRemoteTransactionSourcesMap.keys(), + ); + const chainIdsToRemove = existingChainIds.filter( + (chainId) => !chainIdsInRegistry.has(chainId), + ); + + chainIdsToRemove.forEach((chainId) => { + this.#etherscanRemoteTransactionSourcesMap.delete(chainId); + }); + }; +} diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts index 2f56ceebaa..ff7c244ddc 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts @@ -2,7 +2,6 @@ import { query } from '@metamask/controller-utils'; import type { BlockTracker } from '@metamask/network-controller'; -import type { NonceTracker } from 'nonce-tracker'; import type { TransactionMeta } from '../types'; import { TransactionStatus } from '../types'; @@ -13,6 +12,8 @@ const CHAIN_ID_MOCK = '0x1'; const NONCE_MOCK = '0x2'; const BLOCK_NUMBER_MOCK = '0x123'; +const ETH_QUERY_MOCK = {}; + const TRANSACTION_SUBMITTED_MOCK = { id: ID_MOCK, chainId: CHAIN_ID_MOCK, @@ -52,19 +53,11 @@ function createBlockTrackerMock(): jest.Mocked { } as any; } -function createNonceTrackerMock(): jest.Mocked { - return { - getGlobalLock: () => Promise.resolve({ releaseLock: jest.fn() }), - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; -} - describe('PendingTransactionTracker', () => { const queryMock = jest.mocked(query); let blockTracker: jest.Mocked; let failTransaction: jest.Mock; - let onStateChange: jest.Mock; + let pendingTransactionTracker: PendingTransactionTracker; // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any let options: any; @@ -77,7 +70,7 @@ describe('PendingTransactionTracker', () => { { ...TRANSACTION_SUBMITTED_MOCK }, ]); - onStateChange.mock.calls[0][0](); + pendingTransactionTracker.startIfPendingTransactions(); if (transactionsOnCheck) { options.getTransactions.mockReturnValue(transactionsOnCheck); @@ -91,28 +84,26 @@ describe('PendingTransactionTracker', () => { blockTracker = createBlockTrackerMock(); failTransaction = jest.fn(); - onStateChange = jest.fn(); options = { approveTransaction: jest.fn(), blockTracker, failTransaction, getChainId: () => CHAIN_ID_MOCK, - getEthQuery: () => ({}), + getEthQuery: () => ETH_QUERY_MOCK, getTransactions: jest.fn(), - nonceTracker: createNonceTrackerMock(), - onStateChange, + getGlobalLock: () => Promise.resolve(jest.fn()), publishTransaction: jest.fn(), }; }); describe('on state change', () => { it('adds block tracker listener if pending transactions', () => { - new PendingTransactionTracker(options); + pendingTransactionTracker = new PendingTransactionTracker(options); options.getTransactions.mockReturnValue([TRANSACTION_SUBMITTED_MOCK]); - options.onStateChange.mock.calls[0][0](); + pendingTransactionTracker.startIfPendingTransactions(); expect(blockTracker.on).toHaveBeenCalledTimes(1); expect(blockTracker.on).toHaveBeenCalledWith( @@ -122,29 +113,29 @@ describe('PendingTransactionTracker', () => { }); it('does nothing if block tracker listener already added', () => { - new PendingTransactionTracker(options); + pendingTransactionTracker = new PendingTransactionTracker(options); options.getTransactions.mockReturnValue([TRANSACTION_SUBMITTED_MOCK]); - onStateChange.mock.calls[0][0](); - onStateChange.mock.calls[0][0](); + pendingTransactionTracker.startIfPendingTransactions(); + pendingTransactionTracker.startIfPendingTransactions(); expect(blockTracker.on).toHaveBeenCalledTimes(1); expect(blockTracker.removeListener).toHaveBeenCalledTimes(0); }); it('removes block tracker listener if no pending transactions and running', () => { - new PendingTransactionTracker(options); + pendingTransactionTracker = new PendingTransactionTracker(options); options.getTransactions.mockReturnValue([TRANSACTION_SUBMITTED_MOCK]); - onStateChange.mock.calls[0][0](); + pendingTransactionTracker.startIfPendingTransactions(); expect(blockTracker.removeListener).toHaveBeenCalledTimes(0); options.getTransactions.mockReturnValue([]); - onStateChange.mock.calls[0][0](); + pendingTransactionTracker.startIfPendingTransactions(); expect(blockTracker.removeListener).toHaveBeenCalledTimes(1); expect(blockTracker.removeListener).toHaveBeenCalledWith( @@ -154,21 +145,21 @@ describe('PendingTransactionTracker', () => { }); it('does nothing if block tracker listener already removed', () => { - new PendingTransactionTracker(options); + pendingTransactionTracker = new PendingTransactionTracker(options); options.getTransactions.mockReturnValue([TRANSACTION_SUBMITTED_MOCK]); - onStateChange.mock.calls[0][0](); + pendingTransactionTracker.startIfPendingTransactions(); expect(blockTracker.removeListener).toHaveBeenCalledTimes(0); options.getTransactions.mockReturnValue([]); - onStateChange.mock.calls[0][0](); + pendingTransactionTracker.startIfPendingTransactions(); expect(blockTracker.removeListener).toHaveBeenCalledTimes(1); - onStateChange.mock.calls[0][0](); + pendingTransactionTracker.startIfPendingTransactions(); expect(blockTracker.removeListener).toHaveBeenCalledTimes(1); }); @@ -180,12 +171,24 @@ describe('PendingTransactionTracker', () => { it('if no pending transactions', async () => { const listener = jest.fn(); - const tracker = new PendingTransactionTracker(options); + pendingTransactionTracker = new PendingTransactionTracker(options); - tracker.hub.addListener('transaction-dropped', listener); - tracker.hub.addListener('transaction-failed', listener); - tracker.hub.addListener('transaction-confirmed', listener); - tracker.hub.addListener('transaction-updated', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-dropped', + listener, + ); + pendingTransactionTracker.hub.addListener( + 'transaction-failed', + listener, + ); + pendingTransactionTracker.hub.addListener( + 'transaction-confirmed', + listener, + ); + pendingTransactionTracker.hub.addListener( + 'transaction-updated', + listener, + ); await onLatestBlock(undefined, [ { @@ -212,16 +215,25 @@ describe('PendingTransactionTracker', () => { it('if no receipt', async () => { const listener = jest.fn(); - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [{ ...TRANSACTION_SUBMITTED_MOCK }], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-dropped', listener); - tracker.hub.addListener('transaction-failed', listener); - tracker.hub.addListener('transaction-confirmed', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-dropped', + listener, + ); + pendingTransactionTracker.hub.addListener( + 'transaction-failed', + listener, + ); + pendingTransactionTracker.hub.addListener( + 'transaction-confirmed', + listener, + ); queryMock.mockResolvedValueOnce(undefined); queryMock.mockResolvedValueOnce('0x1'); @@ -234,16 +246,25 @@ describe('PendingTransactionTracker', () => { it('if receipt has no status', async () => { const listener = jest.fn(); - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [{ ...TRANSACTION_SUBMITTED_MOCK }], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-dropped', listener); - tracker.hub.addListener('transaction-failed', listener); - tracker.hub.addListener('transaction-confirmed', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-dropped', + listener, + ); + pendingTransactionTracker.hub.addListener( + 'transaction-failed', + listener, + ); + pendingTransactionTracker.hub.addListener( + 'transaction-confirmed', + listener, + ); queryMock.mockResolvedValueOnce({ ...RECEIPT_MOCK, status: null }); queryMock.mockResolvedValueOnce('0x1'); @@ -256,16 +277,25 @@ describe('PendingTransactionTracker', () => { it('if receipt has invalid status', async () => { const listener = jest.fn(); - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [{ ...TRANSACTION_SUBMITTED_MOCK }], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-dropped', listener); - tracker.hub.addListener('transaction-failed', listener); - tracker.hub.addListener('transaction-confirmed', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-dropped', + listener, + ); + pendingTransactionTracker.hub.addListener( + 'transaction-failed', + listener, + ); + pendingTransactionTracker.hub.addListener( + 'transaction-confirmed', + listener, + ); queryMock.mockResolvedValueOnce({ ...RECEIPT_MOCK, status: '0x3' }); queryMock.mockResolvedValueOnce('0x1'); @@ -285,14 +315,17 @@ describe('PendingTransactionTracker', () => { hash: undefined, }; - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transactionMetaMock], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-failed', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-failed', + listener, + ); await onLatestBlock(); @@ -313,7 +346,7 @@ describe('PendingTransactionTracker', () => { hash: undefined, }; - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transactionMetaMock], hooks: { @@ -324,7 +357,10 @@ describe('PendingTransactionTracker', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-failed', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-failed', + listener, + ); await onLatestBlock(); @@ -334,14 +370,17 @@ describe('PendingTransactionTracker', () => { it('if receipt has error status', async () => { const listener = jest.fn(); - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [{ ...TRANSACTION_SUBMITTED_MOCK }], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-failed', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-failed', + listener, + ); queryMock.mockResolvedValueOnce({ ...RECEIPT_MOCK, status: '0x0' }); @@ -369,7 +408,7 @@ describe('PendingTransactionTracker', () => { ...TRANSACTION_SUBMITTED_MOCK, }; - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [ confirmedTransactionMetaMock, @@ -379,7 +418,10 @@ describe('PendingTransactionTracker', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-dropped', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-dropped', + listener, + ); await onLatestBlock(); @@ -390,14 +432,17 @@ describe('PendingTransactionTracker', () => { it('if nonce exceeded for 3 subsequent blocks', async () => { const listener = jest.fn(); - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [{ ...TRANSACTION_SUBMITTED_MOCK }], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-dropped', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-dropped', + listener, + ); for (let i = 0; i < 4; i++) { expect(listener).toHaveBeenCalledTimes(0); @@ -426,7 +471,7 @@ describe('PendingTransactionTracker', () => { ...TRANSACTION_SUBMITTED_MOCK, }; - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [ confirmedTransactionMetaMock, @@ -436,7 +481,10 @@ describe('PendingTransactionTracker', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-dropped', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-dropped', + listener, + ); await onLatestBlock(); @@ -448,14 +496,17 @@ describe('PendingTransactionTracker', () => { it('if receipt has success status', async () => { const listener = jest.fn(); - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [{ ...TRANSACTION_SUBMITTED_MOCK }], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-confirmed', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-confirmed', + listener, + ); queryMock.mockResolvedValueOnce(RECEIPT_MOCK); queryMock.mockResolvedValueOnce(BLOCK_MOCK); @@ -478,14 +529,17 @@ describe('PendingTransactionTracker', () => { it('if receipt has success status', async () => { const listener = jest.fn(); - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [{ ...TRANSACTION_SUBMITTED_MOCK }], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-updated', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-updated', + listener, + ); queryMock.mockResolvedValueOnce(RECEIPT_MOCK); queryMock.mockResolvedValueOnce(BLOCK_MOCK); @@ -510,14 +564,17 @@ describe('PendingTransactionTracker', () => { const listener = jest.fn(); const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transaction], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-updated', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-updated', + listener, + ); queryMock.mockRejectedValueOnce(new Error('TestError')); queryMock.mockResolvedValueOnce(BLOCK_MOCK); @@ -543,7 +600,7 @@ describe('PendingTransactionTracker', () => { describe('resubmits', () => { describe('does nothing', () => { it('if no pending transactions', async () => { - new PendingTransactionTracker(options); + pendingTransactionTracker = new PendingTransactionTracker(options); await onLatestBlock(undefined, []); @@ -556,14 +613,17 @@ describe('PendingTransactionTracker', () => { it('if first retry check', async () => { const listener = jest.fn(); - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [{ ...TRANSACTION_SUBMITTED_MOCK }], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-updated', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-updated', + listener, + ); queryMock.mockResolvedValueOnce(undefined); queryMock.mockResolvedValueOnce('0x1'); @@ -584,14 +644,17 @@ describe('PendingTransactionTracker', () => { const listener = jest.fn(); const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transaction], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-updated', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-updated', + listener, + ); queryMock.mockResolvedValueOnce(undefined); queryMock.mockResolvedValueOnce('0x1'); @@ -616,7 +679,7 @@ describe('PendingTransactionTracker', () => { ...TRANSACTION_SUBMITTED_MOCK, }; - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transaction], hooks: { @@ -627,7 +690,10 @@ describe('PendingTransactionTracker', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-updated', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-updated', + listener, + ); queryMock.mockResolvedValueOnce(undefined); queryMock.mockResolvedValueOnce('0x1'); @@ -650,14 +716,17 @@ describe('PendingTransactionTracker', () => { const listener = jest.fn(); const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transaction], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-updated', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-updated', + listener, + ); queryMock.mockResolvedValueOnce(undefined); queryMock.mockResolvedValueOnce(BLOCK_MOCK); @@ -688,14 +757,17 @@ describe('PendingTransactionTracker', () => { const listener = jest.fn(); const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; - const tracker = new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transaction], // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - tracker.hub.addListener('transaction-updated', listener); + pendingTransactionTracker.hub.addListener( + 'transaction-updated', + listener, + ); queryMock.mockResolvedValueOnce(undefined); queryMock.mockResolvedValueOnce(BLOCK_MOCK); @@ -719,7 +791,7 @@ describe('PendingTransactionTracker', () => { it('if latest block number increased', async () => { const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; - new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transaction], // TODO: Replace `any` with type @@ -734,6 +806,7 @@ describe('PendingTransactionTracker', () => { expect(options.publishTransaction).toHaveBeenCalledTimes(1); expect(options.publishTransaction).toHaveBeenCalledWith( + ETH_QUERY_MOCK, TRANSACTION_SUBMITTED_MOCK.rawTx, ); }); @@ -741,7 +814,7 @@ describe('PendingTransactionTracker', () => { it('if latest block number matches retry count exponential delay', async () => { const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; - new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transaction], // TODO: Replace `any` with type @@ -776,7 +849,7 @@ describe('PendingTransactionTracker', () => { it('unless resubmit disabled', async () => { const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; - new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transaction], isResubmitEnabled: false, @@ -801,7 +874,7 @@ describe('PendingTransactionTracker', () => { rawTx: undefined, }; - new PendingTransactionTracker({ + pendingTransactionTracker = new PendingTransactionTracker({ ...options, getTransactions: () => [transaction], // TODO: Replace `any` with type @@ -822,4 +895,57 @@ describe('PendingTransactionTracker', () => { }); }); }); + + describe('forceCheckTransaction', () => { + let tracker: PendingTransactionTracker; + let transactionMeta: TransactionMeta; + + beforeEach(() => { + tracker = new PendingTransactionTracker(options); + transactionMeta = { + ...TRANSACTION_SUBMITTED_MOCK, + hash: '0x123', + } as TransactionMeta; + }); + + it('should update transaction status to confirmed if receipt status is success', async () => { + queryMock.mockResolvedValueOnce(RECEIPT_MOCK); + queryMock.mockResolvedValueOnce(BLOCK_MOCK); + options.getTransactions.mockReturnValue([]); + + await tracker.forceCheckTransaction(transactionMeta); + + expect(transactionMeta.status).toStrictEqual(TransactionStatus.confirmed); + expect(transactionMeta.txReceipt).toStrictEqual(RECEIPT_MOCK); + expect(transactionMeta.verifiedOnBlockchain).toBe(true); + }); + + it('should fail transaction if receipt status is failure', async () => { + const receiptMock = { ...RECEIPT_MOCK, status: '0x0' }; + queryMock.mockResolvedValueOnce(receiptMock); + options.getTransactions.mockReturnValue([]); + + const listener = jest.fn(); + tracker.hub.addListener('transaction-failed', listener); + + await tracker.forceCheckTransaction(transactionMeta); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + transactionMeta, + new Error('Transaction dropped or replaced'), + ); + }); + + it('should not change transaction status if receipt status is neither success nor failure', async () => { + const receiptMock = { ...RECEIPT_MOCK, status: '0x2' }; + queryMock.mockResolvedValueOnce(receiptMock); + options.getTransactions.mockReturnValue([]); + + await tracker.forceCheckTransaction(transactionMeta); + + expect(transactionMeta.status).toStrictEqual(TransactionStatus.submitted); + expect(transactionMeta.txReceipt).toBeUndefined(); + }); + }); }); diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index b2af37d5f6..c23bfc3e8c 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -1,9 +1,11 @@ import { query } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import type { BlockTracker } from '@metamask/network-controller'; +import type { + BlockTracker, + NetworkClientId, +} from '@metamask/network-controller'; import { createModuleLogger } from '@metamask/utils'; import EventEmitter from 'events'; -import type { NonceTracker } from 'nonce-tracker'; import { projectLogger } from '../logger'; import type { TransactionMeta, TransactionReceipt } from '../types'; @@ -65,7 +67,7 @@ export class PendingTransactionTracker { #getChainId: () => string; - #getEthQuery: () => EthQuery; + #getEthQuery: (networkClientId?: NetworkClientId) => EthQuery; #getTransactions: () => TransactionMeta[]; @@ -75,11 +77,9 @@ export class PendingTransactionTracker { // eslint-disable-next-line @typescript-eslint/no-explicit-any #listener: any; - #nonceTracker: NonceTracker; + #getGlobalLock: () => Promise<() => void>; - #onStateChange: (listener: () => void) => void; - - #publishTransaction: (rawTx: string) => Promise; + #publishTransaction: (ethQuery: EthQuery, rawTx: string) => Promise; #running: boolean; @@ -94,20 +94,18 @@ export class PendingTransactionTracker { getEthQuery, getTransactions, isResubmitEnabled, - nonceTracker, - onStateChange, + getGlobalLock, publishTransaction, hooks, }: { approveTransaction: (transactionId: string) => Promise; blockTracker: BlockTracker; getChainId: () => string; - getEthQuery: () => EthQuery; + getEthQuery: (networkClientId?: NetworkClientId) => EthQuery; getTransactions: () => TransactionMeta[]; isResubmitEnabled?: boolean; - nonceTracker: NonceTracker; - onStateChange: (listener: () => void) => void; - publishTransaction: (rawTx: string) => Promise; + getGlobalLock: () => Promise<() => void>; + publishTransaction: (ethQuery: EthQuery, rawTx: string) => Promise; hooks?: { beforeCheckPendingTransaction?: ( transactionMeta: TransactionMeta, @@ -125,23 +123,40 @@ export class PendingTransactionTracker { this.#getTransactions = getTransactions; this.#isResubmitEnabled = isResubmitEnabled ?? true; this.#listener = this.#onLatestBlock.bind(this); - this.#nonceTracker = nonceTracker; - this.#onStateChange = onStateChange; + this.#getGlobalLock = getGlobalLock; this.#publishTransaction = publishTransaction; this.#running = false; this.#beforePublish = hooks?.beforePublish ?? (() => true); this.#beforeCheckPendingTransaction = hooks?.beforeCheckPendingTransaction ?? (() => true); + } - this.#onStateChange(() => { - const pendingTransactions = this.#getPendingTransactions(); + startIfPendingTransactions = () => { + const pendingTransactions = this.#getPendingTransactions(); - if (pendingTransactions.length) { - this.#start(); - } else { - this.#stop(); - } - }); + if (pendingTransactions.length) { + this.#start(); + } else { + this.stop(); + } + }; + + /** + * Force checks the network if the given transaction is confirmed and updates it's status. + * + * @param txMeta - The transaction to check + */ + async forceCheckTransaction(txMeta: TransactionMeta) { + const releaseLock = await this.#getGlobalLock(); + + try { + await this.#checkTransaction(txMeta); + } catch (error) { + /* istanbul ignore next */ + log('Failed to check transaction', error); + } finally { + releaseLock(); + } } #start() { @@ -155,7 +170,7 @@ export class PendingTransactionTracker { log('Started polling'); } - #stop() { + stop() { if (!this.#running) { return; } @@ -167,7 +182,7 @@ export class PendingTransactionTracker { } async #onLatestBlock(latestBlockNumber: string) { - const nonceGlobalLock = await this.#nonceTracker.getGlobalLock(); + const releaseLock = await this.#getGlobalLock(); try { await this.#checkTransactions(); @@ -175,7 +190,7 @@ export class PendingTransactionTracker { /* istanbul ignore next */ log('Failed to check transactions', error); } finally { - nonceGlobalLock.releaseLock(); + releaseLock(); } try { @@ -277,7 +292,8 @@ export class PendingTransactionTracker { return; } - await this.#publishTransaction(rawTx); + const ethQuery = this.#getEthQuery(txMeta.networkClientId); + await this.#publishTransaction(ethQuery, rawTx); txMeta.retryCount = (txMeta.retryCount ?? 0) + 1; diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index 23283b42b1..ea4f0a914c 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -3,3 +3,4 @@ export type { EtherscanTransactionMeta } from './utils/etherscan'; export { isEIP1559Transaction } from './utils/utils'; export * from './types'; export { determineTransactionType } from './utils/transaction-type'; +export { mergeGasFeeEstimates } from './utils/gas-flow'; diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index c2610741d0..0d7a9fa564 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1,4 +1,10 @@ import type { AccessList } from '@ethereumjs/tx'; +import type EthQuery from '@metamask/eth-query'; +import type { + FetchGasFeeEstimateOptions, + GasFeeState, +} from '@metamask/gas-fee-controller'; +import type { NetworkClientId } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import type { Operation } from 'fast-json-patch'; @@ -175,6 +181,12 @@ type TransactionMetaBase = { */ firstRetryBlockNumber?: string; + /** Alternate EIP-1559 gas fee estimates for multiple priority levels. */ + gasFeeEstimates?: GasFeeEstimates; + + /** Whether the gas fee estimates have been checked at least once. */ + gasFeeEstimatesLoaded?: boolean; + /** * A hex string of the transaction hash, used to identify the transaction on the network. */ @@ -200,6 +212,11 @@ type TransactionMetaBase = { */ isUserOperation?: boolean; + /** + * The ID of the network client used by the transaction. + */ + networkClientId?: NetworkClientId; + /** * Network code as per EIP-155 for this transaction * @@ -960,3 +977,68 @@ export type SecurityAlertResponse = { result_type: string; providerRequestsCount?: Record; }; + +/** Gas fee estimates for a specific priority level. */ +export type GasFeeEstimatesForLevel = { + /** Maximum amount to pay per gas. */ + maxFeePerGas: Hex; + + /** Maximum amount per gas to give to the validator as an incentive. */ + maxPriorityFeePerGas: Hex; +}; + +/** Alternate priority levels for which values are provided in gas fee estimates. */ +export enum GasFeeEstimateLevel { + low = 'low', + medium = 'medium', + high = 'high', +} + +/** Gas fee estimates for a transaction. */ +export type GasFeeEstimates = { + /** The gas fee estimate for a low priority transaction. */ + [GasFeeEstimateLevel.low]: GasFeeEstimatesForLevel; + + /** The gas fee estimate for a medium priority transaction. */ + [GasFeeEstimateLevel.medium]: GasFeeEstimatesForLevel; + + /** The gas fee estimate for a high priority transaction. */ + [GasFeeEstimateLevel.high]: GasFeeEstimatesForLevel; +}; + +/** Request to a gas fee flow to obtain gas fee estimates. */ +export type GasFeeFlowRequest = { + /** An EthQuery instance to enable queries to the associated RPC provider. */ + ethQuery: EthQuery; + + /** Callback to get the GasFeeController estimates. */ + getGasFeeControllerEstimates: ( + options: FetchGasFeeEstimateOptions, + ) => Promise; + + /** The metadata of the transaction to obtain estimates for. */ + transactionMeta: TransactionMeta; +}; + +/** Response from a gas fee flow containing gas fee estimates. */ +export type GasFeeFlowResponse = { + /** The gas fee estimates for the transaction. */ + estimates: GasFeeEstimates; +}; + +/** A method of obtaining gas fee estimates for a specific transaction. */ +export type GasFeeFlow = { + /** + * Determine if the gas fee flow supports the specified transaction. + * @param transactionMeta - The transaction metadata. + * @returns Whether the gas fee flow supports the transaction. + */ + matchesTransaction(transactionMeta: TransactionMeta): boolean; + + /** + * Get gas fee estimates for a specific transaction. + * @param request - The gas fee flow request. + * @returns The gas fee flow response containing the gas fee estimates. + */ + getGasFees: (request: GasFeeFlowRequest) => Promise; +}; diff --git a/packages/transaction-controller/src/utils/etherscan.test.ts b/packages/transaction-controller/src/utils/etherscan.test.ts index 3087c729f6..222dbc1240 100644 --- a/packages/transaction-controller/src/utils/etherscan.test.ts +++ b/packages/transaction-controller/src/utils/etherscan.test.ts @@ -7,6 +7,7 @@ import type { EtherscanTransactionResponse, } from './etherscan'; import * as Etherscan from './etherscan'; +import { getEtherscanApiHost } from './etherscan'; jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), @@ -37,6 +38,21 @@ describe('Etherscan', () => { jest.resetAllMocks(); }); + describe('getEtherscanApiHost', () => { + it('returns Etherscan API host for supported network', () => { + expect(getEtherscanApiHost(CHAIN_IDS.GOERLI)).toBe( + `https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].subdomain}.${ + ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].domain + }`, + ); + }); + it('returns an error for unsupported network', () => { + expect(() => getEtherscanApiHost('0x11111111111111111111')).toThrow( + 'Etherscan does not support chain with ID: 0x11111111111111111111', + ); + }); + }); + describe.each([ ['fetchEtherscanTransactions', 'txlist'], ['fetchEtherscanTokenTransactions', 'tokentx'], diff --git a/packages/transaction-controller/src/utils/etherscan.ts b/packages/transaction-controller/src/utils/etherscan.ts index ffcaec1dac..cec423cc93 100644 --- a/packages/transaction-controller/src/utils/etherscan.ts +++ b/packages/transaction-controller/src/utils/etherscan.ts @@ -177,15 +177,7 @@ function getEtherscanApiUrl( chainId: Hex, urlParams: Record, ): string { - type SupportedChainId = keyof typeof ETHERSCAN_SUPPORTED_NETWORKS; - - const networkInfo = ETHERSCAN_SUPPORTED_NETWORKS[chainId as SupportedChainId]; - - if (!networkInfo) { - throw new Error(`Etherscan does not support chain with ID: ${chainId}`); - } - - const apiUrl = `https://${networkInfo.subdomain}.${networkInfo.domain}`; + const apiUrl = getEtherscanApiHost(chainId); let url = `${apiUrl}/api?`; for (const paramKey of Object.keys(urlParams)) { @@ -202,3 +194,20 @@ function getEtherscanApiUrl( return url; } + +/** + * Return the host url used to fetch data from Etherscan. + * + * @param chainId - Current chain ID used to determine subdomain and domain. + * @returns host URL to access Etherscan data. + */ +export function getEtherscanApiHost(chainId: Hex) { + // @ts-expect-error We account for `chainId` not being a property below + const networkInfo = ETHERSCAN_SUPPORTED_NETWORKS[chainId]; + + if (!networkInfo) { + throw new Error(`Etherscan does not support chain with ID: ${chainId}`); + } + + return `https://${networkInfo.subdomain}.${networkInfo.domain}`; +} diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index d2cb1dce71..88234990fc 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -1,7 +1,7 @@ /* eslint-disable jsdoc/require-jsdoc */ import { ORIGIN_METAMASK, query } from '@metamask/controller-utils'; -import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; +import type { GasFeeFlow, GasFeeFlowResponse } from '../types'; import { TransactionType, UserFeeLevel } from '../types'; import type { UpdateGasFeesRequest } from './gas-fees'; import { updateGasFees } from './gas-fees'; @@ -33,30 +33,45 @@ function toHex(value: number) { return `0x${value.toString(16)}`; } +/** + * Creates a mock GasFeeFlow. + * @returns The mock GasFeeFlow. + */ +function createGasFeeFlowMock(): jest.Mocked { + return { + matchesTransaction: jest.fn(), + getGasFees: jest.fn(), + }; +} + describe('gas-fees', () => { let updateGasFeeRequest: jest.Mocked; const queryMock = jest.mocked(query); + let gasFeeFlowMock: jest.Mocked; - function mockGetGasFeeEstimates( - estimateType: (typeof GAS_ESTIMATE_TYPES)[keyof typeof GAS_ESTIMATE_TYPES], - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - gasEstimates: any, + function mockGasFeeFlowMockResponse( + maxFeePerGas: string, + maxPriorityFeePerGas: string, ) { - updateGasFeeRequest.getGasFeeEstimates.mockReset(); - updateGasFeeRequest.getGasFeeEstimates.mockResolvedValue({ - gasEstimateType: estimateType, - gasFeeEstimates: gasEstimates, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + gasFeeFlowMock.getGasFees.mockResolvedValue({ + estimates: { + medium: { + maxFeePerGas, + maxPriorityFeePerGas, + }, + }, + } as GasFeeFlowResponse); } beforeEach(() => { + gasFeeFlowMock = createGasFeeFlowMock(); + gasFeeFlowMock.matchesTransaction.mockReturnValue(true); + updateGasFeeRequest = JSON.parse( JSON.stringify(UPDATE_GAS_FEES_REQUEST_MOCK), ); + updateGasFeeRequest.gasFeeFlows = [gasFeeFlowMock]; // eslint-disable-next-line jest/prefer-spy-on updateGasFeeRequest.getSavedGasFees = jest.fn(); // eslint-disable-next-line jest/prefer-spy-on @@ -184,13 +199,8 @@ describe('gas-fees', () => { ); }); - it('to suggested maxFeePerGas if no request values', async () => { - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.FEE_MARKET, { - medium: { - suggestedMaxFeePerGas: `${GAS_MOCK}`, - suggestedMaxPriorityFeePerGas: `456`, - }, - }); + it('to suggested medium maxFeePerGas if no request values', async () => { + mockGasFeeFlowMockResponse(GAS_HEX_WEI_MOCK, GAS_HEX_WEI_MOCK); await updateGasFees(updateGasFeeRequest); @@ -199,28 +209,11 @@ describe('gas-fees', () => { ); }); - it('to suggested maxFeePerGas if request gas price and request maxPriorityFeePerGas', async () => { + it('to suggested medium maxFeePerGas if request gas price and request maxPriorityFeePerGas', async () => { updateGasFeeRequest.txMeta.txParams.gasPrice = '0x456'; updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas = '0x789'; - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.FEE_MARKET, { - medium: { - suggestedMaxFeePerGas: `${GAS_MOCK}`, - suggestedMaxPriorityFeePerGas: `456`, - }, - }); - - await updateGasFees(updateGasFeeRequest); - - expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( - GAS_HEX_WEI_MOCK, - ); - }); - - it('to suggested gasPrice if no request values and estimate type is legacy', async () => { - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.LEGACY, { - medium: `${GAS_MOCK}`, - }); + mockGasFeeFlowMockResponse(GAS_HEX_WEI_MOCK, GAS_HEX_WEI_MOCK); await updateGasFees(updateGasFeeRequest); @@ -229,30 +222,6 @@ describe('gas-fees', () => { ); }); - it('to suggested gasPrice if no request values and estimate type is eth_gasPrice', async () => { - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.ETH_GASPRICE, { - gasPrice: `${GAS_MOCK}`, - }); - - await updateGasFees(updateGasFeeRequest); - - expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( - GAS_HEX_WEI_MOCK, - ); - }); - - it('to suggested gasPrice using RPC method if no request values and no suggested values', async () => { - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.FEE_MARKET, {}); - - queryMock.mockResolvedValueOnce(GAS_MOCK); - - await updateGasFees(updateGasFeeRequest); - - expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( - GAS_HEX_MOCK, - ); - }); - it('to suggested gasPrice using RPC method if no request values and getGasFeeEstimates throws', async () => { updateGasFeeRequest.getGasFeeEstimates.mockReset(); updateGasFeeRequest.getGasFeeEstimates.mockRejectedValueOnce( @@ -315,12 +284,7 @@ describe('gas-fees', () => { }); it('to suggested maxPriorityFeePerGas if no request values', async () => { - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.FEE_MARKET, { - medium: { - suggestedMaxFeePerGas: `456`, - suggestedMaxPriorityFeePerGas: `${GAS_MOCK}`, - }, - }); + mockGasFeeFlowMockResponse(GAS_HEX_WEI_MOCK, GAS_HEX_WEI_MOCK); await updateGasFees(updateGasFeeRequest); @@ -329,16 +293,11 @@ describe('gas-fees', () => { ); }); - it('to suggested maxPriorityFeePerGas if request gas price and request maxFeePerGas', async () => { + it('to suggested medium maxPriorityFeePerGas if request gas price and request maxFeePerGas', async () => { updateGasFeeRequest.txMeta.txParams.gasPrice = '0x456'; updateGasFeeRequest.txMeta.txParams.maxFeePerGas = '0x789'; - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.FEE_MARKET, { - medium: { - suggestedMaxFeePerGas: `456`, - suggestedMaxPriorityFeePerGas: `${GAS_MOCK}`, - }, - }); + mockGasFeeFlowMockResponse(GAS_HEX_WEI_MOCK, GAS_HEX_WEI_MOCK); await updateGasFees(updateGasFeeRequest); @@ -357,42 +316,6 @@ describe('gas-fees', () => { ); }); - it('to suggested gasPrice if no request values and estimate type is legacy', async () => { - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.LEGACY, { - medium: `${GAS_MOCK}`, - }); - - await updateGasFees(updateGasFeeRequest); - - expect(updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas).toBe( - GAS_HEX_WEI_MOCK, - ); - }); - - it('to suggested gasPrice if no request values and estimate type is eth_gasPrice', async () => { - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.ETH_GASPRICE, { - gasPrice: `${GAS_MOCK}`, - }); - - await updateGasFees(updateGasFeeRequest); - - expect(updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas).toBe( - GAS_HEX_WEI_MOCK, - ); - }); - - it('to suggested gasPrice using RPC method if no request values and no suggested values', async () => { - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.FEE_MARKET, {}); - - queryMock.mockResolvedValueOnce(GAS_MOCK); - - await updateGasFees(updateGasFeeRequest); - - expect(updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas).toBe( - GAS_HEX_MOCK, - ); - }); - it('to suggested gasPrice if no request values and getGasFeeEstimates throws', async () => { updateGasFeeRequest.getGasFeeEstimates.mockReset(); updateGasFeeRequest.getGasFeeEstimates.mockRejectedValueOnce( @@ -430,26 +353,10 @@ describe('gas-fees', () => { ); }); - it('to suggested gasPrice if no request gasPrice and estimate type is legacy', async () => { - updateGasFeeRequest.eip1559 = false; - - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.LEGACY, { - medium: `${GAS_MOCK}`, - }); - - await updateGasFees(updateGasFeeRequest); - - expect(updateGasFeeRequest.txMeta.txParams.gasPrice).toBe( - GAS_HEX_WEI_MOCK, - ); - }); - - it('to suggested gasPrice if no request gasPrice and estimate type is eth_gasPrice', async () => { + it('to suggested medium maxFeePerGas if no request gasPrice', async () => { updateGasFeeRequest.eip1559 = false; - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.ETH_GASPRICE, { - gasPrice: `${GAS_MOCK}`, - }); + mockGasFeeFlowMockResponse(GAS_HEX_WEI_MOCK, GAS_HEX_WEI_MOCK); await updateGasFees(updateGasFeeRequest); @@ -458,18 +365,6 @@ describe('gas-fees', () => { ); }); - it('to suggested gasPrice using RPC method if no request gasPrice and no suggested values', async () => { - updateGasFeeRequest.eip1559 = false; - - queryMock.mockResolvedValueOnce(GAS_MOCK); - - await updateGasFees(updateGasFeeRequest); - - expect(updateGasFeeRequest.txMeta.txParams.gasPrice).toBe( - GAS_HEX_MOCK, - ); - }); - it('to suggested gasPrice if no request gasPrice and getGasFeeEstimates throws', async () => { updateGasFeeRequest.eip1559 = false; @@ -535,12 +430,7 @@ describe('gas-fees', () => { }); it('to medium if suggested maxFeePerGas and maxPriorityFeePerGas but no request maxFeePerGas or maxPriorityFeePerGas', async () => { - mockGetGasFeeEstimates(GAS_ESTIMATE_TYPES.FEE_MARKET, { - medium: { - suggestedMaxFeePerGas: `${GAS_MOCK}`, - suggestedMaxPriorityFeePerGas: `${GAS_MOCK}`, - }, - }); + mockGasFeeFlowMockResponse(GAS_HEX_MOCK, GAS_HEX_MOCK); await updateGasFees(updateGasFeeRequest); diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index b550470488..882af0a49a 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -7,10 +7,12 @@ import { toHex, } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import type { GasFeeState } from '@metamask/gas-fee-controller'; -import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import { createModuleLogger } from '@metamask/utils'; -import { addHexPrefix } from 'ethereumjs-util'; +import type { + FetchGasFeeEstimateOptions, + GasFeeState, +} from '@metamask/gas-fee-controller'; +import type { Hex } from '@metamask/utils'; +import { add0x, createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../logger'; import type { @@ -18,22 +20,33 @@ import type { TransactionParams, TransactionMeta, TransactionType, + GasFeeFlow, } from '../types'; import { UserFeeLevel } from '../types'; +import { getGasFeeFlow } from './gas-flow'; import { SWAP_TRANSACTION_TYPES } from './swaps'; export type UpdateGasFeesRequest = { eip1559: boolean; ethQuery: EthQuery; - getSavedGasFees: () => SavedGasFees | undefined; - getGasFeeEstimates: () => Promise; + gasFeeFlows: GasFeeFlow[]; + getGasFeeEstimates: ( + options: FetchGasFeeEstimateOptions, + ) => Promise; + getSavedGasFees: (chainId: Hex) => SavedGasFees | undefined; txMeta: TransactionMeta; }; export type GetGasFeeRequest = UpdateGasFeesRequest & { - savedGasFees?: SavedGasFees; initialParams: TransactionParams; - suggestedGasFees: Awaited>; + savedGasFees?: SavedGasFees; + suggestedGasFees: SuggestedGasFees; +}; + +type SuggestedGasFees = { + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + gasPrice?: string; }; const log = createModuleLogger(projectLogger, 'gas-fees'); @@ -45,16 +58,18 @@ export async function updateGasFees(request: UpdateGasFeesRequest) { const isSwap = SWAP_TRANSACTION_TYPES.includes( txMeta.type as TransactionType, ); - const savedGasFees = isSwap ? undefined : request.getSavedGasFees(); + const savedGasFees = isSwap + ? undefined + : request.getSavedGasFees(txMeta.chainId); const suggestedGasFees = await getSuggestedGasFees(request); log('Suggested gas fees', suggestedGasFees); - const getGasFeeRequest = { + const getGasFeeRequest: GetGasFeeRequest = { ...request, - savedGasFees, initialParams, + savedGasFees, suggestedGasFees, }; @@ -84,6 +99,10 @@ export async function updateGasFees(request: UpdateGasFeesRequest) { updateDefaultGasEstimates(txMeta); } +export function gweiDecimalToWeiHex(value: string) { + return toHex(gweiDecToWEIBN(value)); +} + function getMaxFeePerGas(request: GetGasFeeRequest): string | undefined { const { savedGasFees, eip1559, initialParams, suggestedGasFees } = request; @@ -194,6 +213,11 @@ function getGasPrice(request: GetGasFeeRequest): string | undefined { return initialParams.gasPrice; } + if (suggestedGasFees.maxFeePerGas) { + log('Using suggested maxFeePerGas', suggestedGasFees.maxFeePerGas); + return suggestedGasFees.maxFeePerGas; + } + if (suggestedGasFees.gasPrice) { log('Using suggested gasPrice', suggestedGasFees.gasPrice); return suggestedGasFees.gasPrice; @@ -255,8 +279,11 @@ function updateDefaultGasEstimates(txMeta: TransactionMeta) { txMeta.defaultGasEstimates.estimateType = txMeta.userFeeLevel; } -async function getSuggestedGasFees(request: UpdateGasFeesRequest) { - const { eip1559, ethQuery, getGasFeeEstimates, txMeta } = request; +async function getSuggestedGasFees( + request: UpdateGasFeesRequest, +): Promise { + const { eip1559, ethQuery, gasFeeFlows, getGasFeeEstimates, txMeta } = + request; if ( (!eip1559 && txMeta.txParams.gasPrice) || @@ -267,39 +294,16 @@ async function getSuggestedGasFees(request: UpdateGasFeesRequest) { return {}; } + const gasFeeFlow = getGasFeeFlow(txMeta, gasFeeFlows) as GasFeeFlow; + try { - const { gasFeeEstimates, gasEstimateType } = await getGasFeeEstimates(); - - if (eip1559 && gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { - const { - medium: { suggestedMaxPriorityFeePerGas, suggestedMaxFeePerGas } = {}, - } = gasFeeEstimates; - - if (suggestedMaxPriorityFeePerGas && suggestedMaxFeePerGas) { - return { - maxFeePerGas: gweiDecimalToWeiHex(suggestedMaxFeePerGas), - maxPriorityFeePerGas: gweiDecimalToWeiHex( - suggestedMaxPriorityFeePerGas, - ), - }; - } - } - - if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { - // The LEGACY type includes low, medium and high estimates of - // gas price values. - return { - gasPrice: gweiDecimalToWeiHex(gasFeeEstimates.medium), - }; - } - - if (gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE) { - // The ETH_GASPRICE type just includes a single gas price property, - // which we can assume was retrieved from eth_gasPrice - return { - gasPrice: gweiDecimalToWeiHex(gasFeeEstimates.gasPrice), - }; - } + const response = await gasFeeFlow.getGasFees({ + ethQuery, + getGasFeeControllerEstimates: getGasFeeEstimates, + transactionMeta: txMeta, + }); + + return response.estimates.medium; } catch (error) { log('Failed to get suggested gas fees', error); } @@ -307,12 +311,8 @@ async function getSuggestedGasFees(request: UpdateGasFeesRequest) { const gasPriceDecimal = (await query(ethQuery, 'gasPrice')) as number; const gasPrice = gasPriceDecimal - ? addHexPrefix(gasPriceDecimal.toString(16)) + ? add0x(gasPriceDecimal.toString(16)) : undefined; return { gasPrice }; } - -function gweiDecimalToWeiHex(value: string) { - return toHex(gweiDecToWEIBN(value)); -} diff --git a/packages/transaction-controller/src/utils/gas-flow.test.ts b/packages/transaction-controller/src/utils/gas-flow.test.ts new file mode 100644 index 0000000000..c10816108c --- /dev/null +++ b/packages/transaction-controller/src/utils/gas-flow.test.ts @@ -0,0 +1,157 @@ +import type { + GasFeeEstimates as GasFeeControllerEstimates, + LegacyGasPriceEstimate, +} from '@metamask/gas-fee-controller'; +import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; + +import type { GasFeeEstimates, GasFeeFlow, TransactionMeta } from '../types'; +import { TransactionStatus } from '../types'; +import { getGasFeeFlow, mergeGasFeeEstimates } from './gas-flow'; + +const TRANSACTION_META_MOCK: TransactionMeta = { + id: '1', + chainId: '0x123', + status: TransactionStatus.unapproved, + time: 0, + txParams: { + from: '0x123', + }, +}; + +const GAS_FEE_CONTROLLER_FEE_MARKET_ESTIMATES_MOCK = { + baseFeeTrend: 'up', + low: { + minWaitTimeEstimate: 10, + maxWaitTimeEstimate: 20, + suggestedMaxFeePerGas: '1', + suggestedMaxPriorityFeePerGas: '2', + }, + medium: { + minWaitTimeEstimate: 30, + maxWaitTimeEstimate: 40, + suggestedMaxFeePerGas: '3', + suggestedMaxPriorityFeePerGas: '4', + }, + high: { + minWaitTimeEstimate: 50, + maxWaitTimeEstimate: 60, + suggestedMaxFeePerGas: '5', + suggestedMaxPriorityFeePerGas: '6', + }, +} as GasFeeControllerEstimates; + +const GAS_FEE_CONTROLLER_LEGACY_ESTIMATES_MOCK: LegacyGasPriceEstimate = { + low: '1', + medium: '2', + high: '3', +}; + +const TRANSACTION_GAS_FEE_ESTIMATES_MOCK: GasFeeEstimates = { + low: { + maxFeePerGas: '0x7', + maxPriorityFeePerGas: '0x8', + }, + medium: { + maxFeePerGas: '0x9', + maxPriorityFeePerGas: '0xA', + }, + high: { + maxFeePerGas: '0xB', + maxPriorityFeePerGas: '0xC', + }, +}; + +/** + * Creates a mock GasFeeFlow. + * @returns The mock GasFeeFlow. + */ +function createGasFeeFlowMock(): jest.Mocked { + return { + matchesTransaction: jest.fn(), + getGasFees: jest.fn(), + }; +} + +describe('gas-flow', () => { + describe('getGasFeeFlow', () => { + it('returns undefined if no gas fee flow matches transaction', () => { + const gasFeeFlow1 = createGasFeeFlowMock(); + const gasFeeFlow2 = createGasFeeFlowMock(); + + gasFeeFlow1.matchesTransaction.mockReturnValue(false); + gasFeeFlow2.matchesTransaction.mockReturnValue(false); + + expect( + getGasFeeFlow(TRANSACTION_META_MOCK, [gasFeeFlow1, gasFeeFlow2]), + ).toBeUndefined(); + }); + + it('returns first gas fee flow that matches transaction', () => { + const gasFeeFlow1 = createGasFeeFlowMock(); + const gasFeeFlow2 = createGasFeeFlowMock(); + + gasFeeFlow1.matchesTransaction.mockReturnValue(false); + gasFeeFlow2.matchesTransaction.mockReturnValue(true); + + expect( + getGasFeeFlow(TRANSACTION_META_MOCK, [gasFeeFlow1, gasFeeFlow2]), + ).toBe(gasFeeFlow2); + }); + }); + + describe('mergeGasFeeEstimates', () => { + it('uses transaction estimates and other gas fee controller properties if estimate type is fee market', () => { + const result = mergeGasFeeEstimates({ + gasFeeControllerEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + gasFeeControllerEstimates: GAS_FEE_CONTROLLER_FEE_MARKET_ESTIMATES_MOCK, + transactionGasFeeEstimates: TRANSACTION_GAS_FEE_ESTIMATES_MOCK, + }); + + expect(result).toStrictEqual({ + baseFeeTrend: 'up', + low: { + minWaitTimeEstimate: 10, + maxWaitTimeEstimate: 20, + suggestedMaxFeePerGas: '0.000000007', + suggestedMaxPriorityFeePerGas: '0.000000008', + }, + medium: { + minWaitTimeEstimate: 30, + maxWaitTimeEstimate: 40, + suggestedMaxFeePerGas: '0.000000009', + suggestedMaxPriorityFeePerGas: '0.00000001', + }, + high: { + minWaitTimeEstimate: 50, + maxWaitTimeEstimate: 60, + suggestedMaxFeePerGas: '0.000000011', + suggestedMaxPriorityFeePerGas: '0.000000012', + }, + }); + }); + + it('uses transaction estimates only if estimate type is legacy', () => { + const result = mergeGasFeeEstimates({ + gasFeeControllerEstimateType: GAS_ESTIMATE_TYPES.LEGACY, + gasFeeControllerEstimates: GAS_FEE_CONTROLLER_LEGACY_ESTIMATES_MOCK, + transactionGasFeeEstimates: TRANSACTION_GAS_FEE_ESTIMATES_MOCK, + }); + + expect(result).toStrictEqual({ + low: '0.000000007', + medium: '0.000000009', + high: '0.000000011', + }); + }); + + it('uses unchanged gas fee controller estimates if estimate type is gas price', () => { + const result = mergeGasFeeEstimates({ + gasFeeControllerEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + gasFeeControllerEstimates: GAS_FEE_CONTROLLER_LEGACY_ESTIMATES_MOCK, + transactionGasFeeEstimates: TRANSACTION_GAS_FEE_ESTIMATES_MOCK, + } as never); + + expect(result).toStrictEqual(GAS_FEE_CONTROLLER_LEGACY_ESTIMATES_MOCK); + }); + }); +}); diff --git a/packages/transaction-controller/src/utils/gas-flow.ts b/packages/transaction-controller/src/utils/gas-flow.ts new file mode 100644 index 0000000000..2d0c2e08b7 --- /dev/null +++ b/packages/transaction-controller/src/utils/gas-flow.ts @@ -0,0 +1,119 @@ +import { weiHexToGweiDec } from '@metamask/controller-utils'; +import type { + Eip1559GasFee, + GasFeeEstimates, + LegacyGasPriceEstimate, +} from '@metamask/gas-fee-controller'; +import { + GAS_ESTIMATE_TYPES, + type GasFeeState, +} from '@metamask/gas-fee-controller'; + +import { + type GasFeeEstimates as TransactionGasFeeEstimates, + type GasFeeFlow, + type TransactionMeta, + type GasFeeEstimatesForLevel, + GasFeeEstimateLevel, +} from '../types'; + +/** + * Returns the first gas fee flow that matches the transaction. + * + * @param transactionMeta - The transaction metadata to find a gas fee flow for. + * @param gasFeeFlows - The gas fee flows to search. + * @returns The first gas fee flow that matches the transaction, or undefined if none match. + */ +export function getGasFeeFlow( + transactionMeta: TransactionMeta, + gasFeeFlows: GasFeeFlow[], +): GasFeeFlow | undefined { + return gasFeeFlows.find((gasFeeFlow) => + gasFeeFlow.matchesTransaction(transactionMeta), + ); +} + +type FeeMarketMergeGasFeeEstimatesRequest = { + gasFeeControllerEstimateType: 'fee-market'; + gasFeeControllerEstimates: GasFeeEstimates; + transactionGasFeeEstimates: TransactionGasFeeEstimates; +}; + +type LegacyMergeGasFeeEstimatesRequest = { + gasFeeControllerEstimateType: 'legacy'; + gasFeeControllerEstimates: LegacyGasPriceEstimate; + transactionGasFeeEstimates: TransactionGasFeeEstimates; +}; + +/** + * Merge the gas fee estimates from the gas fee controller with the gas fee estimates from a transaction. + * @param request - Data required to merge gas fee estimates. + * @param request.gasFeeControllerEstimateType - Gas fee estimate type from the gas fee controller. + * @param request.gasFeeControllerEstimates - Gas fee estimates from the GasFeeController. + * @param request.transactionGasFeeEstimates - Gas fee estimates from the transaction. + * @returns The merged gas fee estimates. + */ +export function mergeGasFeeEstimates({ + gasFeeControllerEstimateType, + gasFeeControllerEstimates, + transactionGasFeeEstimates, +}: + | FeeMarketMergeGasFeeEstimatesRequest + | LegacyMergeGasFeeEstimatesRequest): GasFeeState['gasFeeEstimates'] { + if (gasFeeControllerEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { + return Object.values(GasFeeEstimateLevel).reduce( + (result, level) => ({ + ...result, + [level]: mergeFeeMarketEstimate( + gasFeeControllerEstimates[level], + transactionGasFeeEstimates[level], + ), + }), + { ...gasFeeControllerEstimates } as GasFeeEstimates, + ); + } + + if (gasFeeControllerEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { + return Object.values(GasFeeEstimateLevel).reduce( + (result, level) => ({ + ...result, + [level]: getLegacyEstimate(transactionGasFeeEstimates[level]), + }), + {} as LegacyGasPriceEstimate, + ); + } + + return gasFeeControllerEstimates; +} + +/** + * Merge a specific priority level of EIP-1559 gas fee estimates. + * @param gasFeeControllerEstimate - The gas fee estimate from the gas fee controller. + * @param transactionGasFeeEstimate - The gas fee estimate from the transaction. + * @returns The merged gas fee estimate. + */ +function mergeFeeMarketEstimate( + gasFeeControllerEstimate: Eip1559GasFee, + transactionGasFeeEstimate: GasFeeEstimatesForLevel, +): Eip1559GasFee { + return { + ...gasFeeControllerEstimate, + suggestedMaxFeePerGas: weiHexToGweiDec( + transactionGasFeeEstimate.maxFeePerGas, + ), + suggestedMaxPriorityFeePerGas: weiHexToGweiDec( + transactionGasFeeEstimate.maxPriorityFeePerGas, + ), + }; +} + +/** + * Generate a specific priority level for a legacy gas fee estimate. + * @param transactionGasFeeEstimate - The gas fee estimate from the transaction. + * @returns The legacy gas fee estimate. + */ +function getLegacyEstimate( + transactionGasFeeEstimate: GasFeeEstimatesForLevel, +): string { + return weiHexToGweiDec(transactionGasFeeEstimate.maxFeePerGas); +} diff --git a/packages/transaction-controller/src/utils/gas.test.ts b/packages/transaction-controller/src/utils/gas.test.ts index 23c7fe0670..53e66f73e8 100644 --- a/packages/transaction-controller/src/utils/gas.test.ts +++ b/packages/transaction-controller/src/utils/gas.test.ts @@ -1,6 +1,6 @@ /* eslint-disable jsdoc/require-jsdoc */ -import { NetworkType, query } from '@metamask/controller-utils'; +import { query } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; import { CHAIN_IDS } from '../constants'; @@ -37,7 +37,8 @@ const TRANSACTION_META_MOCK = { const UPDATE_GAS_REQUEST_MOCK = { txMeta: TRANSACTION_META_MOCK, - providerConfig: {}, + chainId: '0x0', + isCustomNetwork: false, ethQuery: ETH_QUERY_MOCK, } as UpdateGasRequest; @@ -117,7 +118,7 @@ describe('gas', () => { }); it('to estimate if custom network', async () => { - updateGasRequest.providerConfig.type = NetworkType.rpc; + updateGasRequest.isCustomNetwork = true; mockQuery({ getBlockByNumberResponse: { gasLimit: toHex(BLOCK_GAS_LIMIT_MOCK) }, @@ -133,7 +134,7 @@ describe('gas', () => { }); it('to estimate if not custom network and no to parameter', async () => { - updateGasRequest.providerConfig.type = NetworkType.mainnet; + updateGasRequest.isCustomNetwork = false; const gasEstimation = Math.ceil(GAS_MOCK * DEFAULT_GAS_MULTIPLIER); delete updateGasRequest.txMeta.txParams.to; mockQuery({ @@ -190,7 +191,7 @@ describe('gas', () => { const estimatedGasPadded = Math.ceil(blockGasLimit90Percent - 10); const estimatedGas = estimatedGasPadded; // Optimism multiplier is 1 - updateGasRequest.providerConfig.chainId = CHAIN_IDS.OPTIMISM; + updateGasRequest.chainId = CHAIN_IDS.OPTIMISM; mockQuery({ getBlockByNumberResponse: { gasLimit: toHex(BLOCK_GAS_LIMIT_MOCK) }, @@ -229,7 +230,7 @@ describe('gas', () => { describe('to fixed value', () => { it('if not custom network and to parameter and no data and no code', async () => { - updateGasRequest.providerConfig.type = NetworkType.mainnet; + updateGasRequest.isCustomNetwork = false; delete updateGasRequest.txMeta.txParams.data; mockQuery({ @@ -246,7 +247,7 @@ describe('gas', () => { }); it('if not custom network and to parameter and no data and empty code', async () => { - updateGasRequest.providerConfig.type = NetworkType.mainnet; + updateGasRequest.isCustomNetwork = false; delete updateGasRequest.txMeta.txParams.data; mockQuery({ diff --git a/packages/transaction-controller/src/utils/gas.ts b/packages/transaction-controller/src/utils/gas.ts index 961578c4f9..d0095481f9 100644 --- a/packages/transaction-controller/src/utils/gas.ts +++ b/packages/transaction-controller/src/utils/gas.ts @@ -2,15 +2,13 @@ import { BNToHex, - NetworkType, fractionBN, hexToBN, query, } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import type { ProviderConfig } from '@metamask/network-controller'; -import { createModuleLogger } from '@metamask/utils'; -import { addHexPrefix } from 'ethereumjs-util'; +import type { Hex } from '@metamask/utils'; +import { add0x, createModuleLogger } from '@metamask/utils'; import { GAS_BUFFER_CHAIN_OVERRIDES } from '../constants'; import { projectLogger } from '../logger'; @@ -18,7 +16,8 @@ import type { TransactionMeta, TransactionParams } from '../types'; export type UpdateGasRequest = { ethQuery: EthQuery; - providerConfig: ProviderConfig; + isCustomNetwork: boolean; + chainId: Hex; txMeta: TransactionMeta; }; @@ -60,7 +59,7 @@ export async function estimateGas( const gasLimitBN = hexToBN(gasLimitHex); - request.data = data ? addHexPrefix(data) : data; + request.data = data ? add0x(data) : data; request.gas = BNToHex(fractionBN(gasLimitBN, 19, 20)); request.value = value || '0x0'; @@ -101,18 +100,18 @@ export function addGasBuffer( const paddedGasBN = estimatedGasBN.muln(multiplier); if (estimatedGasBN.gt(maxGasBN)) { - const estimatedGasHex = addHexPrefix(estimatedGas); + const estimatedGasHex = add0x(estimatedGas); log('Using estimated value', estimatedGasHex); return estimatedGasHex; } if (paddedGasBN.lt(maxGasBN)) { - const paddedHex = addHexPrefix(BNToHex(paddedGasBN)); + const paddedHex = add0x(BNToHex(paddedGasBN)); log('Using padded estimate', paddedHex, multiplier); return paddedHex; } - const maxHex = addHexPrefix(BNToHex(maxGasBN)); + const maxHex = add0x(BNToHex(maxGasBN)); log('Using 90% of block gas limit', maxHex); return maxHex; } @@ -120,7 +119,7 @@ export function addGasBuffer( async function getGas( request: UpdateGasRequest, ): Promise<[string, TransactionMeta['simulationFails']?]> { - const { providerConfig, txMeta } = request; + const { isCustomNetwork, chainId, txMeta } = request; if (txMeta.txParams.gas) { log('Using value from request', txMeta.txParams.gas); @@ -137,14 +136,14 @@ async function getGas( request.ethQuery, ); - if (providerConfig.type === NetworkType.rpc) { + if (isCustomNetwork) { log('Using original estimate as custom network'); return [estimatedGas, simulationFails]; } const bufferMultiplier = GAS_BUFFER_CHAIN_OVERRIDES[ - providerConfig.chainId as keyof typeof GAS_BUFFER_CHAIN_OVERRIDES + chainId as keyof typeof GAS_BUFFER_CHAIN_OVERRIDES ] ?? DEFAULT_GAS_MULTIPLIER; const bufferedGas = addGasBuffer( @@ -159,10 +158,8 @@ async function getGas( async function requiresFixedGas({ ethQuery, txMeta, - providerConfig, + isCustomNetwork, }: UpdateGasRequest): Promise { - const isCustomNetwork = providerConfig.type === NetworkType.rpc; - const { txParams: { to, data }, } = txMeta; diff --git a/packages/transaction-controller/src/utils/nonce.test.ts b/packages/transaction-controller/src/utils/nonce.test.ts index e48cdd46c5..87238f3a69 100644 --- a/packages/transaction-controller/src/utils/nonce.test.ts +++ b/packages/transaction-controller/src/utils/nonce.test.ts @@ -1,5 +1,5 @@ import type { - NonceTracker, + NonceLock, Transaction as NonceTrackerTransaction, } from 'nonce-tracker'; @@ -17,16 +17,6 @@ const TRANSACTION_META_MOCK: TransactionMeta = { }, }; -/** - * Creates a mock instance of a nonce tracker. - * @returns The mock instance. - */ -function createNonceTrackerMock(): jest.Mocked { - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { getNonceLock: jest.fn() } as any; -} - describe('nonce', () => { describe('getNextNonce', () => { it('returns custom nonce if provided', async () => { @@ -35,11 +25,9 @@ describe('nonce', () => { customNonceValue: '123', }; - const nonceTracker = createNonceTrackerMock(); - const [nonce, releaseLock] = await getNextNonce( transactionMeta, - nonceTracker, + jest.fn(), ); expect(nonce).toBe('0x7b'); @@ -55,11 +43,9 @@ describe('nonce', () => { }, }; - const nonceTracker = createNonceTrackerMock(); - const [nonce, releaseLock] = await getNextNonce( transactionMeta, - nonceTracker, + jest.fn(), ); expect(nonce).toBe('0x123'); @@ -74,19 +60,15 @@ describe('nonce', () => { }, }; - const nonceTracker = createNonceTrackerMock(); const releaseLock = jest.fn(); - nonceTracker.getNonceLock.mockResolvedValueOnce({ - nextNonce: 456, - releaseLock, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - const [nonce, resultReleaseLock] = await getNextNonce( transactionMeta, - nonceTracker, + () => + Promise.resolve({ + nextNonce: 456, + releaseLock, + } as unknown as NonceLock), ); expect(nonce).toBe('0x1c8'); diff --git a/packages/transaction-controller/src/utils/nonce.ts b/packages/transaction-controller/src/utils/nonce.ts index 346a5b400a..545f3a8156 100644 --- a/packages/transaction-controller/src/utils/nonce.ts +++ b/packages/transaction-controller/src/utils/nonce.ts @@ -1,6 +1,6 @@ import { toHex } from '@metamask/controller-utils'; import type { - NonceTracker, + NonceLock, Transaction as NonceTrackerTransaction, } from 'nonce-tracker'; @@ -13,12 +13,12 @@ const log = createModuleLogger(projectLogger, 'nonce'); * Determine the next nonce to be used for a transaction. * * @param txMeta - The transaction metadata. - * @param nonceTracker - An instance of a nonce tracker. + * @param getNonceLock - An anonymous function that acquires the nonce lock for an address * @returns The next hexadecimal nonce to be used for the given transaction, and optionally a function to release the nonce lock. */ export async function getNextNonce( txMeta: TransactionMeta, - nonceTracker: NonceTracker, + getNonceLock: (address: string) => Promise, ): Promise<[string, (() => void) | undefined]> { const { customNonceValue, @@ -37,7 +37,7 @@ export async function getNextNonce( return [existingNonce, undefined]; } - const nonceLock = await nonceTracker.getNonceLock(from); + const nonceLock = await getNonceLock(from); const nonce = toHex(nonceLock.nextNonce); const releaseLock = nonceLock.releaseLock.bind(nonceLock); diff --git a/packages/transaction-controller/src/utils/utils.ts b/packages/transaction-controller/src/utils/utils.ts index dcaad556c0..b4382414bf 100644 --- a/packages/transaction-controller/src/utils/utils.ts +++ b/packages/transaction-controller/src/utils/utils.ts @@ -1,6 +1,9 @@ import { convertHexToDecimal } from '@metamask/controller-utils'; -import { getKnownPropertyNames } from '@metamask/utils'; -import { addHexPrefix, isHexString } from 'ethereumjs-util'; +import { + add0x, + getKnownPropertyNames, + isStrictHexString, +} from '@metamask/utils'; import type { GasPriceValue, @@ -18,20 +21,20 @@ export const ESTIMATE_GAS_ERROR = 'eth_estimateGas rpc method error'; // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const NORMALIZERS: { [param in keyof TransactionParams]: any } = { - data: (data: string) => addHexPrefix(data), - from: (from: string) => addHexPrefix(from).toLowerCase(), - gas: (gas: string) => addHexPrefix(gas), - gasLimit: (gas: string) => addHexPrefix(gas), - gasPrice: (gasPrice: string) => addHexPrefix(gasPrice), - nonce: (nonce: string) => addHexPrefix(nonce), - to: (to: string) => addHexPrefix(to).toLowerCase(), - value: (value: string) => addHexPrefix(value), - maxFeePerGas: (maxFeePerGas: string) => addHexPrefix(maxFeePerGas), + data: (data: string) => add0x(data), + from: (from: string) => add0x(from).toLowerCase(), + gas: (gas: string) => add0x(gas), + gasLimit: (gas: string) => add0x(gas), + gasPrice: (gasPrice: string) => add0x(gasPrice), + nonce: (nonce: string) => add0x(nonce), + to: (to: string) => add0x(to).toLowerCase(), + value: (value: string) => add0x(value), + maxFeePerGas: (maxFeePerGas: string) => add0x(maxFeePerGas), maxPriorityFeePerGas: (maxPriorityFeePerGas: string) => - addHexPrefix(maxPriorityFeePerGas), + add0x(maxPriorityFeePerGas), estimatedBaseFee: (maxPriorityFeePerGas: string) => - addHexPrefix(maxPriorityFeePerGas), - type: (type: string) => addHexPrefix(type), + add0x(maxPriorityFeePerGas), + type: (type: string) => add0x(type), }; /** @@ -79,7 +82,7 @@ export const validateGasValues = ( // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const value = (gasValues as any)[key]; - if (typeof value !== 'string' || !isHexString(value)) { + if (typeof value !== 'string' || !isStrictHexString(value)) { throw new TypeError( `expected hex string for ${key} but received: ${value}`, ); @@ -99,7 +102,7 @@ export const isGasPriceValue = ( (gasValues as GasPriceValue)?.gasPrice !== undefined; export const getIncreasedPriceHex = (value: number, rate: number): string => - addHexPrefix(`${parseInt(`${value * rate}`, 10).toString(16)}`); + add0x(`${parseInt(`${value * rate}`, 10).toString(16)}`); export const getIncreasedPriceFromExisting = ( value: string | undefined, @@ -175,7 +178,7 @@ export function normalizeGasFeeValues( // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const normalize = (value: any) => - typeof value === 'string' ? addHexPrefix(value) : value; + typeof value === 'string' ? add0x(value) : value; if ('gasPrice' in gasFeeValues) { return { diff --git a/packages/transaction-controller/tests/EtherscanMocks.ts b/packages/transaction-controller/tests/EtherscanMocks.ts new file mode 100644 index 0000000000..6598f9b9bc --- /dev/null +++ b/packages/transaction-controller/tests/EtherscanMocks.ts @@ -0,0 +1,134 @@ +import { TransactionStatus, TransactionType } from '../src/types'; +import type { + EtherscanTokenTransactionMeta, + EtherscanTransactionMeta, + EtherscanTransactionMetaBase, + EtherscanTransactionResponse, +} from '../src/utils/etherscan'; + +export const ID_MOCK = '6843ba00-f4bf-11e8-a715-5f2fff84549d'; + +export const ETHERSCAN_TRANSACTION_BASE_MOCK: EtherscanTransactionMetaBase = { + blockNumber: '4535105', + confirmations: '4', + contractAddress: '', + cumulativeGasUsed: '693910', + from: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207', + gas: '335208', + gasPrice: '20000000000', + gasUsed: '21000', + hash: '0x342e9d73e10004af41d04973339fc7219dbadcbb5629730cfe65e9f9cb15ff91', + nonce: '1', + timeStamp: '1543596356', + transactionIndex: '13', + value: '50000000000000000', + blockHash: '0x0000000001', + to: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207', +}; + +export const ETHERSCAN_TRANSACTION_SUCCESS_MOCK: EtherscanTransactionMeta = { + ...ETHERSCAN_TRANSACTION_BASE_MOCK, + functionName: 'testFunction', + input: '0x', + isError: '0', + methodId: 'testId', + txreceipt_status: '1', +}; + +const ETHERSCAN_TRANSACTION_ERROR_MOCK: EtherscanTransactionMeta = { + ...ETHERSCAN_TRANSACTION_SUCCESS_MOCK, + isError: '1', +}; + +export const ETHERSCAN_TOKEN_TRANSACTION_MOCK: EtherscanTokenTransactionMeta = { + ...ETHERSCAN_TRANSACTION_BASE_MOCK, + tokenDecimal: '456', + tokenName: 'TestToken', + tokenSymbol: 'ABC', +}; + +export const ETHERSCAN_TRANSACTION_RESPONSE_MOCK: EtherscanTransactionResponse = + { + status: '1', + result: [ + ETHERSCAN_TRANSACTION_SUCCESS_MOCK, + ETHERSCAN_TRANSACTION_ERROR_MOCK, + ], + }; + +export const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK: EtherscanTransactionResponse = + { + status: '1', + result: [ + ETHERSCAN_TOKEN_TRANSACTION_MOCK, + ETHERSCAN_TOKEN_TRANSACTION_MOCK, + ], + }; + +export const ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK: EtherscanTransactionResponse = + { + status: '0', + result: '', + }; + +export const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_EMPTY_MOCK: EtherscanTransactionResponse = + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK as any; + +export const ETHERSCAN_TRANSACTION_RESPONSE_ERROR_MOCK: EtherscanTransactionResponse = + { + status: '0', + message: 'NOTOK', + result: 'Test Error', + }; + +export const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_ERROR_MOCK: EtherscanTransactionResponse = + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ETHERSCAN_TRANSACTION_RESPONSE_ERROR_MOCK as any; + +const EXPECTED_NORMALISED_TRANSACTION_BASE = { + blockNumber: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.blockNumber, + chainId: undefined, + hash: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.hash, + id: ID_MOCK, + status: TransactionStatus.confirmed, + time: 1543596356000, + txParams: { + chainId: undefined, + from: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.from, + gas: '0x51d68', + gasPrice: '0x4a817c800', + gasUsed: '0x5208', + nonce: '0x1', + to: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.to, + value: '0xb1a2bc2ec50000', + }, + type: TransactionType.incoming, + verifiedOnBlockchain: false, +}; + +export const EXPECTED_NORMALISED_TRANSACTION_SUCCESS = { + ...EXPECTED_NORMALISED_TRANSACTION_BASE, + txParams: { + ...EXPECTED_NORMALISED_TRANSACTION_BASE.txParams, + data: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.input, + }, +}; + +export const EXPECTED_NORMALISED_TRANSACTION_ERROR = { + ...EXPECTED_NORMALISED_TRANSACTION_SUCCESS, + error: new Error('Transaction failed'), + status: TransactionStatus.failed, +}; + +export const EXPECTED_NORMALISED_TOKEN_TRANSACTION = { + ...EXPECTED_NORMALISED_TRANSACTION_BASE, + isTransfer: true, + transferInformation: { + contractAddress: '', + decimals: Number(ETHERSCAN_TOKEN_TRANSACTION_MOCK.tokenDecimal), + symbol: ETHERSCAN_TOKEN_TRANSACTION_MOCK.tokenSymbol, + }, +}; diff --git a/packages/transaction-controller/tests/JsonRpcRequestMocks.ts b/packages/transaction-controller/tests/JsonRpcRequestMocks.ts new file mode 100644 index 0000000000..101009fce5 --- /dev/null +++ b/packages/transaction-controller/tests/JsonRpcRequestMocks.ts @@ -0,0 +1,230 @@ +import type { Hex } from '@metamask/utils'; + +import type { JsonRpcRequestMock } from '../../../tests/mock-network'; + +/** + * Builds mock eth_gasPrice request. + * Used by getSuggestedGasFees. + * + * @param result - the hex gas price result. + * @returns The mock json rpc request object. + */ +export function buildEthGasPriceRequestMock( + result: Hex = '0x1', +): JsonRpcRequestMock { + return { + request: { + method: 'eth_gasPrice', + params: [], + }, + response: { + result, + }, + }; +} + +/** + * Builds mock eth_blockNumber request. + * Used by NetworkController and BlockTracker. + * + * @param result - the hex block number result. + * @returns The mock json rpc request object. + */ +export function buildEthBlockNumberRequestMock( + result: Hex, +): JsonRpcRequestMock { + return { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result, + }, + }; +} + +/** + * Builds mock eth_getCode request. + * Used by readAddressAsContract and requiresFixedGas. + * + * @param address - The hex address. + * @param blockNumber - The hex block number. + * @param result - the hex code result. + * @returns The mock json rpc request object. + */ +export function buildEthGetCodeRequestMock( + address: Hex, + blockNumber: Hex = '0x1', + result: Hex = '0x', +): JsonRpcRequestMock { + return { + request: { + method: 'eth_getCode', + params: [address, blockNumber], + }, + response: { + result, + }, + }; +} + +/** + * Builds mock eth_getBlockByNumber request. + * Used by NetworkController. + * + * @param number - the hex (block) number. + * @param baseFeePerGas - the hex base fee per gas result. + * @returns The mock json rpc request object. + */ +export function buildEthGetBlockByNumberRequestMock( + number: Hex, + baseFeePerGas: Hex = '0x63c498a46', +): JsonRpcRequestMock { + return { + request: { + method: 'eth_getBlockByNumber', + params: [number, false], + }, + response: { + result: { + baseFeePerGas, + number, + }, + }, + }; +} + +/** + * Builds mock eth_estimateGas request. + * Used by estimateGas. + * + * @param from - The hex from address. + * @param to - The hex to address. + * @param result - the hex gas result. + * @returns The mock json rpc request object. + */ +export function buildEthEstimateGasRequestMock( + from: Hex, + to: Hex, + result: Hex = '0x1', +): JsonRpcRequestMock { + return { + request: { + method: 'eth_estimateGas', + params: [ + { + from, + to, + value: '0x0', + gas: '0x0', + }, + ], + }, + response: { + result, + }, + }; +} + +/** + * Builds mock eth_getTransactionCount request. + * Used by NonceTracker. + * + * @param address - The hex address. + * @param blockNumber - The hex block number. + * @param result - the hex transaction count result. + * @returns The mock json rpc request object. + */ +export function buildEthGetTransactionCountRequestMock( + address: Hex, + blockNumber: Hex = '0x1', + result: Hex = '0x1', +): JsonRpcRequestMock { + return { + request: { + method: 'eth_getTransactionCount', + params: [address, blockNumber], + }, + response: { + result, + }, + }; +} + +/** + * Builds mock eth_getBlockByHash request. + * Used by PendingTransactionTracker.#onTransactionConfirmed. + * + * @param blockhash - The hex block hash. + * @returns The mock json rpc request object. + */ +export function buildEthGetBlockByHashRequestMock( + blockhash: Hex, +): JsonRpcRequestMock { + return { + request: { + method: 'eth_getBlockByHash', + params: [blockhash, false], + }, + response: { + result: { + transactions: [], + }, + }, + }; +} + +/** + * Builds mock eth_sendRawTransaction request. + * Used by publishTransaction. + * + * @param txData - The hex signed transaction data. + * @param result - the hex transaction hash result. + * @returns The mock json rpc request object. + */ +export function buildEthSendRawTransactionRequestMock( + txData: Hex, + result: Hex, +): JsonRpcRequestMock { + return { + request: { + method: 'eth_sendRawTransaction', + params: [txData], + }, + response: { + result, + }, + }; +} + +/** + * Builds mock eth_getTransactionReceipt request. + * Used by PendingTransactionTracker.#checkTransaction. + * + * @param txHash - The hex transaction hash. + * @param blockHash - the hex transaction hash result. + * @param blockNumber - the hex block number result. + * @param status - the hex status result. + * @returns The mock json rpc request object. + */ +export function buildEthGetTransactionReceiptRequestMock( + txHash: Hex, + blockHash: Hex, + blockNumber: Hex, + status: Hex = '0x1', +): JsonRpcRequestMock { + return { + request: { + method: 'eth_getTransactionReceipt', + params: [txHash], + }, + response: { + result: { + blockHash, + blockNumber, + status, + }, + }, + }; +} diff --git a/packages/user-operation-controller/CHANGELOG.md b/packages/user-operation-controller/CHANGELOG.md index f398df80aa..43f87934f4 100644 --- a/packages/user-operation-controller/CHANGELOG.md +++ b/packages/user-operation-controller/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +## [4.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` dependency and peer dependency to `^23.0.0` ([#3925](https://github.com/MetaMask/core/pull/3925)) + ## [3.0.0] ### Changed @@ -47,7 +53,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial Release ([#3749](https://github.com/MetaMask/core/pull/3749)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@3.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@4.0.0...HEAD +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@3.0.0...@metamask/user-operation-controller@4.0.0 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@2.0.0...@metamask/user-operation-controller@3.0.0 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/user-operation-controller@1.0.0...@metamask/user-operation-controller@2.0.0 [1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/user-operation-controller@1.0.0 diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index e61efd9d91..a660491654 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/user-operation-controller", - "version": "3.0.0", + "version": "4.0.0", "description": "Creates user operations and manages their life cycle", "keywords": [ "MetaMask", @@ -34,16 +34,16 @@ "dependencies": { "@metamask/approval-controller": "^5.1.2", "@metamask/base-controller": "^4.1.1", - "@metamask/controller-utils": "^8.0.2", + "@metamask/controller-utils": "^8.0.3", "@metamask/eth-query": "^4.0.0", - "@metamask/gas-fee-controller": "^13.0.0", + "@metamask/gas-fee-controller": "^13.0.1", "@metamask/keyring-controller": "^12.2.0", "@metamask/network-controller": "^17.2.0", "@metamask/polling-controller": "^5.0.0", - "@metamask/rpc-errors": "^6.1.0", - "@metamask/transaction-controller": "^21.2.0", + "@metamask/rpc-errors": "^6.2.1", + "@metamask/transaction-controller": "^23.1.0", "@metamask/utils": "^8.3.0", - "ethereumjs-util": "^7.0.10", + "bn.js": "^5.2.1", "immer": "^9.0.6", "lodash": "^4.17.21", "superstruct": "^1.0.3", @@ -64,7 +64,7 @@ "@metamask/gas-fee-controller": "^13.0.0", "@metamask/keyring-controller": "^12.2.0", "@metamask/network-controller": "^17.2.0", - "@metamask/transaction-controller": "^21.2.0" + "@metamask/transaction-controller": "^23.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/user-operation-controller/src/UserOperationController.ts b/packages/user-operation-controller/src/UserOperationController.ts index bc3cabc7af..5e82673ff7 100644 --- a/packages/user-operation-controller/src/UserOperationController.ts +++ b/packages/user-operation-controller/src/UserOperationController.ts @@ -24,7 +24,7 @@ import { type TransactionParams, type TransactionType, } from '@metamask/transaction-controller'; -import { addHexPrefix } from 'ethereumjs-util'; +import { add0x } from '@metamask/utils'; import EventEmitter from 'events'; import type { Patch } from 'immer'; import { cloneDeep } from 'lodash'; @@ -758,11 +758,11 @@ export class UserOperationController extends BaseController< const { userOperation } = metadata; const usingPaymaster = userOperation.paymasterAndData !== EMPTY_BYTES; - const updatedMaxFeePerGas = addHexPrefix( + const updatedMaxFeePerGas = add0x( updatedTransaction.txParams.maxFeePerGas as string, ); - const updatedMaxPriorityFeePerGas = addHexPrefix( + const updatedMaxPriorityFeePerGas = add0x( updatedTransaction.txParams.maxPriorityFeePerGas as string, ); diff --git a/packages/user-operation-controller/src/utils/gas-fees.ts b/packages/user-operation-controller/src/utils/gas-fees.ts index 0327935266..8cb9876892 100644 --- a/packages/user-operation-controller/src/utils/gas-fees.ts +++ b/packages/user-operation-controller/src/utils/gas-fees.ts @@ -13,7 +13,7 @@ import type { Provider } from '@metamask/network-controller'; import type { TransactionParams } from '@metamask/transaction-controller'; import { UserFeeLevel } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { addHexPrefix } from 'ethereumjs-util'; +import { add0x } from '@metamask/utils'; import { EMPTY_BYTES } from '../constants'; import { createModuleLogger, projectLogger } from '../logger'; @@ -272,7 +272,7 @@ async function getSuggestedGasFees( return {}; } - const maxFeePerGas = addHexPrefix(gasPriceDecimal.toString(16)) as Hex; + const maxFeePerGas = add0x(gasPriceDecimal.toString(16)) as Hex; log('Using gasPrice from network as fallback', maxFeePerGas); diff --git a/packages/user-operation-controller/src/utils/gas.ts b/packages/user-operation-controller/src/utils/gas.ts index c27f3b08cf..94ca9d70f9 100644 --- a/packages/user-operation-controller/src/utils/gas.ts +++ b/packages/user-operation-controller/src/utils/gas.ts @@ -1,5 +1,6 @@ import { hexToBN } from '@metamask/controller-utils'; -import { BN, addHexPrefix } from 'ethereumjs-util'; +import { add0x } from '@metamask/utils'; +import BN from 'bn.js'; import { VALUE_ZERO } from '../constants'; import { Bundler } from '../helpers/Bundler'; @@ -86,5 +87,5 @@ function normalizeGasEstimate(rawValue: string | number): string { const bufferedValue = value.muln(GAS_ESTIMATE_MULTIPLIER); - return addHexPrefix(bufferedValue.toString(16)); + return add0x(bufferedValue.toString(16)); } diff --git a/packages/user-operation-controller/src/utils/transaction.ts b/packages/user-operation-controller/src/utils/transaction.ts index 1cc489bbed..5bb1161c7e 100644 --- a/packages/user-operation-controller/src/utils/transaction.ts +++ b/packages/user-operation-controller/src/utils/transaction.ts @@ -8,7 +8,8 @@ import { UserFeeLevel, } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { BN, addHexPrefix, stripHexPrefix } from 'ethereumjs-util'; +import { add0x, remove0x } from '@metamask/utils'; +import BN from 'bn.js'; import { EMPTY_BYTES, VALUE_ZERO } from '../constants'; import { UserOperationStatus } from '../types'; @@ -46,9 +47,9 @@ export function getTransactionMetadata( // effectiveGasPrice = actualGasCost / actualGasUsed const effectiveGasPrice = actualGasCost && actualGasUsed - ? addHexPrefix( - new BN(stripHexPrefix(actualGasCost), 16) - .div(new BN(stripHexPrefix(actualGasUsed), 16)) + ? add0x( + new BN(remove0x(actualGasCost), 16) + .div(new BN(remove0x(actualGasUsed), 16)) .toString(16), ) : undefined; @@ -152,8 +153,8 @@ function addHex(...values: (string | undefined)[]) { continue; } - total.iadd(new BN(stripHexPrefix(value), 16)); + total.iadd(new BN(remove0x(value), 16)); } - return addHexPrefix(total.toString(16)); + return add0x(total.toString(16)); } diff --git a/tests/mock-network.ts b/tests/mock-network.ts index 9581cc6448..b4b5b90fd1 100644 --- a/tests/mock-network.ts +++ b/tests/mock-network.ts @@ -29,7 +29,7 @@ import { NetworkClientType } from '../packages/network-controller/src/types'; * when the promise is initiated but before it is resolved). You can pass an * function (optionally async) to do this. */ -type JsonRpcRequestMock = { +export type JsonRpcRequestMock = { request: { method: string; // TODO: Replace `any` with type diff --git a/yarn.lock b/yarn.lock index c2b4cab67d..74711dc673 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1441,29 +1441,29 @@ __metadata: languageName: node linkType: hard -"@lavamoat/aa@npm:^3.1.1": - version: 3.1.2 - resolution: "@lavamoat/aa@npm:3.1.2" +"@lavamoat/aa@npm:^4.0.1": + version: 4.0.1 + resolution: "@lavamoat/aa@npm:4.0.1" dependencies: - resolve: ^1.20.0 + resolve: 1.22.8 bin: lavamoat-ls: src/cli.js - checksum: e580278f2119e26b968105b1ba61d9285537b2577b2f2a256c12d4e623bc544eb7664989855b90e7b2aee6ed23222179423a0c9009f67995ded85678e132332f + checksum: ec49d058bd169a358d702c8d3672faf2228458f56d1d85c9738eff6924f5f2d5e24c2c693d1937fee49795155176890804c9dc68a51738662fa5b917931af280 languageName: node linkType: hard -"@lavamoat/allow-scripts@npm:^2.3.1": - version: 2.3.1 - resolution: "@lavamoat/allow-scripts@npm:2.3.1" +"@lavamoat/allow-scripts@npm:^3.0.2": + version: 3.0.2 + resolution: "@lavamoat/allow-scripts@npm:3.0.2" dependencies: - "@lavamoat/aa": ^3.1.1 - "@npmcli/run-script": ^6.0.0 - bin-links: 4.0.1 - npm-normalize-package-bin: ^3.0.0 - yargs: ^16.2.0 + "@lavamoat/aa": ^4.0.1 + "@npmcli/run-script": 7.0.4 + bin-links: 4.0.3 + npm-normalize-package-bin: 3.0.1 + yargs: 17.7.2 bin: allow-scripts: src/cli.js - checksum: 334612c1ecd357f0143542837ba9982b16e884e4091083b7f437ddc48e79071e3e5503bc3eaa65adf5aa84e4e3021abc074438dd202a72b80ad6fff785caad69 + checksum: 2a8fc1629845990121d41f8b52f85b7b835c9aeb9a1659172c14ecb7dbb985845d4bb396bdac58e1fc570fc6e4e6025c90b16a69691082456e27c7c30acf073b languageName: node linkType: hard @@ -1481,6 +1481,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/accounts-controller@workspace:packages/accounts-controller" dependencies: + "@ethereumjs/util": ^8.1.0 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 "@metamask/eth-snap-keyring": ^2.1.1 @@ -1493,7 +1494,7 @@ __metadata: "@types/jest": ^27.4.1 "@types/readable-stream": ^2.3.0 deepmerge: ^4.2.2 - ethereumjs-util: ^7.0.10 + ethereum-cryptography: ^2.1.2 immer: ^9.0.6 jest: ^27.5.1 ts-jest: ^27.1.4 @@ -1524,7 +1525,7 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 @@ -1558,7 +1559,7 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/rpc-errors": ^6.1.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 @@ -1576,6 +1577,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: + "@ethereumjs/util": ^8.1.0 "@ethersproject/address": ^5.7.0 "@ethersproject/bignumber": ^5.7.0 "@ethersproject/contracts": ^5.7.0 @@ -1586,7 +1588,7 @@ __metadata: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 "@metamask/contract-metadata": ^2.4.0 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/eth-query": ^4.0.0 "@metamask/ethjs-provider-http": ^0.3.0 "@metamask/keyring-api": ^3.0.0 @@ -1595,16 +1597,17 @@ __metadata: "@metamask/network-controller": ^17.2.0 "@metamask/polling-controller": ^5.0.0 "@metamask/preferences-controller": ^7.0.0 - "@metamask/rpc-errors": ^6.1.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/utils": ^8.3.0 + "@types/bn.js": ^5.1.5 "@types/jest": ^27.4.1 "@types/lodash": ^4.14.191 "@types/node": ^16.18.54 "@types/uuid": ^8.3.0 async-mutex: ^0.2.6 + bn.js: ^5.2.1 cockatiel: ^3.1.2 deepmerge: ^4.2.2 - ethereumjs-util: ^7.0.10 jest: ^27.5.1 jest-environment-jsdom: ^27.5.1 lodash: ^4.17.21 @@ -1708,6 +1711,8 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 + "@metamask/json-rpc-engine": ^7.3.2 + "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 immer: ^9.0.6 @@ -1727,10 +1732,11 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@^8.0.1, @metamask/controller-utils@^8.0.2, @metamask/controller-utils@workspace:packages/controller-utils": +"@metamask/controller-utils@^8.0.1, @metamask/controller-utils@^8.0.3, @metamask/controller-utils@workspace:packages/controller-utils": version: 0.0.0-use.local resolution: "@metamask/controller-utils@workspace:packages/controller-utils" dependencies: + "@ethereumjs/util": ^8.1.0 "@metamask/auto-changelog": ^3.4.4 "@metamask/eth-query": ^4.0.0 "@metamask/ethjs-unit": ^0.3.0 @@ -1740,7 +1746,6 @@ __metadata: bn.js: ^5.2.1 deepmerge: ^4.2.2 eth-ens-namehash: ^2.0.8 - ethereumjs-util: ^7.0.10 fast-deep-equal: ^3.1.3 jest: ^27.5.1 nock: ^13.3.1 @@ -1758,7 +1763,7 @@ __metadata: "@babel/core": ^7.23.5 "@babel/plugin-transform-modules-commonjs": ^7.23.3 "@babel/preset-typescript": ^7.23.3 - "@lavamoat/allow-scripts": ^2.3.1 + "@lavamoat/allow-scripts": ^3.0.2 "@metamask/create-release-branch": ^3.0.0 "@metamask/eslint-config": ^12.2.0 "@metamask/eslint-config-jest": ^12.1.0 @@ -1828,7 +1833,7 @@ __metadata: "@ethersproject/providers": ^5.7.0 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/network-controller": ^17.2.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2109,23 +2114,24 @@ __metadata: languageName: node linkType: hard -"@metamask/gas-fee-controller@^13.0.0, @metamask/gas-fee-controller@workspace:packages/gas-fee-controller": +"@metamask/gas-fee-controller@^13.0.1, @metamask/gas-fee-controller@workspace:packages/gas-fee-controller": version: 0.0.0-use.local resolution: "@metamask/gas-fee-controller@workspace:packages/gas-fee-controller" dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/eth-query": ^4.0.0 "@metamask/ethjs-unit": ^0.3.0 "@metamask/network-controller": ^17.2.0 "@metamask/polling-controller": ^5.0.0 "@metamask/utils": ^8.3.0 + "@types/bn.js": ^5.1.5 "@types/jest": ^27.4.1 "@types/jest-when": ^2.7.3 "@types/uuid": ^8.3.0 + bn.js: ^5.2.1 deepmerge: ^4.2.2 - ethereumjs-util: ^7.0.10 jest: ^27.5.1 jest-when: ^3.4.2 nock: ^13.3.1 @@ -2144,9 +2150,9 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/json-rpc-engine@workspace:packages/json-rpc-engine" dependencies: - "@lavamoat/allow-scripts": ^2.3.1 + "@lavamoat/allow-scripts": ^3.0.2 "@metamask/auto-changelog": ^3.4.4 - "@metamask/rpc-errors": ^6.1.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/safe-event-emitter": ^3.0.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2232,9 +2238,10 @@ __metadata: dependencies: "@ethereumjs/common": ^3.2.0 "@ethereumjs/tx": ^4.2.0 + "@ethereumjs/util": ^8.1.0 "@keystonehq/bc-ur-registry-eth": ^0.9.0 "@keystonehq/metamask-airgapped-keyring": ^0.13.1 - "@lavamoat/allow-scripts": ^2.3.1 + "@lavamoat/allow-scripts": ^3.0.2 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 "@metamask/browser-passworder": ^4.3.0 @@ -2248,7 +2255,6 @@ __metadata: "@types/jest": ^27.4.1 async-mutex: ^0.2.6 deepmerge: ^4.2.2 - ethereumjs-util: ^7.0.10 ethereumjs-wallet: ^1.0.1 immer: ^9.0.6 jest: ^27.5.1 @@ -2268,7 +2274,7 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 jest: ^27.5.1 @@ -2286,13 +2292,12 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/eth-sig-util": ^7.0.1 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 "@types/uuid": ^8.3.0 deepmerge: ^4.2.2 - ethereumjs-util: ^7.0.10 jest: ^27.5.1 jsonschema: ^1.2.4 ts-jest: ^27.1.4 @@ -2335,13 +2340,13 @@ __metadata: "@json-rpc-specification/meta-schema": ^1.0.6 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/eth-json-rpc-infura": ^9.0.0 "@metamask/eth-json-rpc-middleware": ^12.1.0 "@metamask/eth-json-rpc-provider": ^2.3.2 "@metamask/eth-query": ^4.0.0 "@metamask/json-rpc-engine": ^7.3.2 - "@metamask/rpc-errors": ^6.1.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/swappable-obj-proxy": ^2.2.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2412,35 +2417,16 @@ __metadata: languageName: node linkType: hard -"@metamask/permission-controller@npm:^7.0.0, @metamask/permission-controller@npm:^7.1.0": - version: 7.1.0 - resolution: "@metamask/permission-controller@npm:7.1.0" - dependencies: - "@metamask/base-controller": ^4.0.1 - "@metamask/controller-utils": ^8.0.1 - "@metamask/json-rpc-engine": ^7.3.1 - "@metamask/rpc-errors": ^6.1.0 - "@metamask/utils": ^8.2.0 - "@types/deep-freeze-strict": ^1.1.0 - deep-freeze-strict: ^1.1.1 - immer: ^9.0.6 - nanoid: ^3.1.31 - peerDependencies: - "@metamask/approval-controller": ^5.1.1 - checksum: 889213cca32cbf5b32b7e71c70ded0aeea32eae169ec67fb0d0bc8dcaa183b222f9d5417f657e331d7fb21ecb71f250cf1c932110d4b1e2167972b30bd012098 - languageName: node - linkType: hard - -"@metamask/permission-controller@workspace:packages/permission-controller": +"@metamask/permission-controller@^8.0.1, @metamask/permission-controller@workspace:packages/permission-controller": version: 0.0.0-use.local resolution: "@metamask/permission-controller@workspace:packages/permission-controller" dependencies: "@metamask/approval-controller": ^5.1.2 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/json-rpc-engine": ^7.3.2 - "@metamask/rpc-errors": ^6.1.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/utils": ^8.3.0 "@types/deep-freeze-strict": ^1.1.0 "@types/jest": ^27.4.1 @@ -2458,6 +2444,25 @@ __metadata: languageName: unknown linkType: soft +"@metamask/permission-controller@npm:^7.0.0, @metamask/permission-controller@npm:^7.1.0": + version: 7.1.0 + resolution: "@metamask/permission-controller@npm:7.1.0" + dependencies: + "@metamask/base-controller": ^4.0.1 + "@metamask/controller-utils": ^8.0.1 + "@metamask/json-rpc-engine": ^7.3.1 + "@metamask/rpc-errors": ^6.1.0 + "@metamask/utils": ^8.2.0 + "@types/deep-freeze-strict": ^1.1.0 + deep-freeze-strict: ^1.1.1 + immer: ^9.0.6 + nanoid: ^3.1.31 + peerDependencies: + "@metamask/approval-controller": ^5.1.1 + checksum: 889213cca32cbf5b32b7e71c70ded0aeea32eae169ec67fb0d0bc8dcaa183b222f9d5417f657e331d7fb21ecb71f250cf1c932110d4b1e2167972b30bd012098 + languageName: node + linkType: hard + "@metamask/permission-log-controller@workspace:packages/permission-log-controller": version: 0.0.0-use.local resolution: "@metamask/permission-log-controller@workspace:packages/permission-log-controller" @@ -2485,7 +2490,7 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@types/jest": ^27.4.1 "@types/punycode": ^2.1.0 deepmerge: ^4.2.2 @@ -2507,7 +2512,7 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/network-controller": ^17.2.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2542,7 +2547,7 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/keyring-controller": ^12.2.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 @@ -2584,11 +2589,11 @@ __metadata: "@metamask/approval-controller": ^5.1.2 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/json-rpc-engine": ^7.3.2 "@metamask/network-controller": ^17.2.0 - "@metamask/rpc-errors": ^6.1.0 - "@metamask/selected-network-controller": ^7.0.1 + "@metamask/rpc-errors": ^6.2.1 + "@metamask/selected-network-controller": ^8.0.0 "@metamask/swappable-obj-proxy": ^2.2.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2605,7 +2610,7 @@ __metadata: peerDependencies: "@metamask/approval-controller": ^5.1.2 "@metamask/network-controller": ^17.2.0 - "@metamask/selected-network-controller": ^7.0.1 + "@metamask/selected-network-controller": ^8.0.0 languageName: unknown linkType: soft @@ -2615,7 +2620,7 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/rpc-errors": ^6.1.0 + "@metamask/rpc-errors": ^6.2.1 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 jest: ^27.5.1 @@ -2626,13 +2631,13 @@ __metadata: languageName: unknown linkType: soft -"@metamask/rpc-errors@npm:^6.0.0, @metamask/rpc-errors@npm:^6.1.0": - version: 6.1.0 - resolution: "@metamask/rpc-errors@npm:6.1.0" +"@metamask/rpc-errors@npm:^6.0.0, @metamask/rpc-errors@npm:^6.1.0, @metamask/rpc-errors@npm:^6.2.1": + version: 6.2.1 + resolution: "@metamask/rpc-errors@npm:6.2.1" dependencies: - "@metamask/utils": ^8.1.0 + "@metamask/utils": ^8.3.0 fast-safe-stringify: ^2.0.6 - checksum: 9f4821d804e2fcaa8987b0958d02c6d829b7c7db49740c811cb593f381d0c4b00dabb7f1802907f1b2f6126f7c0d83ec34219183d29650f5d24df014ac72906a + checksum: a9223c3cb9ab05734ea0dda990597f90a7cdb143efa0c026b1a970f2094fe5fa3c341ed39b1e7623be13a96b98fb2c697ef51a2e2b87d8f048114841d35ee0a9 languageName: node linkType: hard @@ -2660,7 +2665,7 @@ __metadata: languageName: node linkType: hard -"@metamask/selected-network-controller@^7.0.1, @metamask/selected-network-controller@workspace:packages/selected-network-controller": +"@metamask/selected-network-controller@^8.0.0, @metamask/selected-network-controller@workspace:packages/selected-network-controller": version: 0.0.0-use.local resolution: "@metamask/selected-network-controller@workspace:packages/selected-network-controller" dependencies: @@ -2668,6 +2673,7 @@ __metadata: "@metamask/base-controller": ^4.1.1 "@metamask/json-rpc-engine": ^7.3.2 "@metamask/network-controller": ^17.2.0 + "@metamask/permission-controller": ^8.0.1 "@metamask/swappable-obj-proxy": ^2.2.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2693,15 +2699,14 @@ __metadata: "@metamask/approval-controller": ^5.1.2 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/keyring-controller": ^12.2.0 "@metamask/logging-controller": ^2.0.2 "@metamask/message-manager": ^7.3.8 - "@metamask/rpc-errors": ^6.1.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 - ethereumjs-util: ^7.0.10 jest: ^27.5.1 lodash: ^4.17.21 ts-jest: ^27.1.4 @@ -2889,34 +2894,37 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@^21.2.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@^23.1.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: "@babel/runtime": ^7.23.9 "@ethereumjs/common": ^3.2.0 "@ethereumjs/tx": ^4.2.0 + "@ethereumjs/util": ^8.1.0 "@ethersproject/abi": ^5.7.0 "@metamask/approval-controller": ^5.1.2 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/eth-query": ^4.0.0 "@metamask/ethjs-provider-http": ^0.3.0 - "@metamask/gas-fee-controller": ^13.0.0 + "@metamask/gas-fee-controller": ^13.0.1 "@metamask/metamask-eth-abis": ^3.0.0 "@metamask/network-controller": ^17.2.0 - "@metamask/rpc-errors": ^6.1.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/utils": ^8.3.0 + "@types/bn.js": ^5.1.5 "@types/jest": ^27.4.1 "@types/node": ^16.18.54 async-mutex: ^0.2.6 + bn.js: ^5.2.1 deepmerge: ^4.2.2 eth-method-registry: ^4.0.0 - ethereumjs-util: ^7.0.10 fast-json-patch: ^3.1.1 jest: ^27.5.1 lodash: ^4.17.21 + nock: ^13.3.1 nonce-tracker: ^3.0.0 sinon: ^9.2.4 ts-jest: ^27.1.4 @@ -2939,18 +2947,18 @@ __metadata: "@metamask/approval-controller": ^5.1.2 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/controller-utils": ^8.0.2 + "@metamask/controller-utils": ^8.0.3 "@metamask/eth-query": ^4.0.0 - "@metamask/gas-fee-controller": ^13.0.0 + "@metamask/gas-fee-controller": ^13.0.1 "@metamask/keyring-controller": ^12.2.0 "@metamask/network-controller": ^17.2.0 "@metamask/polling-controller": ^5.0.0 - "@metamask/rpc-errors": ^6.1.0 - "@metamask/transaction-controller": ^21.2.0 + "@metamask/rpc-errors": ^6.2.1 + "@metamask/transaction-controller": ^23.1.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 + bn.js: ^5.2.1 deepmerge: ^4.2.2 - ethereumjs-util: ^7.0.10 immer: ^9.0.6 jest: ^27.5.1 lodash: ^4.17.21 @@ -2965,7 +2973,7 @@ __metadata: "@metamask/gas-fee-controller": ^13.0.0 "@metamask/keyring-controller": ^12.2.0 "@metamask/network-controller": ^17.2.0 - "@metamask/transaction-controller": ^21.2.0 + "@metamask/transaction-controller": ^23.0.0 languageName: unknown linkType: soft @@ -3100,6 +3108,19 @@ __metadata: languageName: node linkType: hard +"@npmcli/agent@npm:^2.0.0": + version: 2.2.1 + resolution: "@npmcli/agent@npm:2.2.1" + dependencies: + agent-base: ^7.1.0 + http-proxy-agent: ^7.0.0 + https-proxy-agent: ^7.0.1 + lru-cache: ^10.0.1 + socks-proxy-agent: ^8.0.1 + checksum: c69aca42dbba393f517bc5777ee872d38dc98ea0e5e93c1f6d62b82b8fecdc177a57ea045f07dda1a770c592384b2dd92a5e79e21e2a7cf51c9159466a8f9c9b + languageName: node + linkType: hard + "@npmcli/fs@npm:^3.1.0": version: 3.1.0 resolution: "@npmcli/fs@npm:3.1.0" @@ -3109,6 +3130,22 @@ __metadata: languageName: node linkType: hard +"@npmcli/git@npm:^5.0.0": + version: 5.0.4 + resolution: "@npmcli/git@npm:5.0.4" + dependencies: + "@npmcli/promise-spawn": ^7.0.0 + lru-cache: ^10.0.1 + npm-pick-manifest: ^9.0.0 + proc-log: ^3.0.0 + promise-inflight: ^1.0.1 + promise-retry: ^2.0.1 + semver: ^7.3.5 + which: ^4.0.0 + checksum: 3c4adb7294eb7562cb0d908f36e1967ae6bde438192affd7f103cdeebbd9b2d83cd6b41b7db2278c9acd934c4af138baa094544e8e8a530b515c4084438d0170 + languageName: node + linkType: hard + "@npmcli/node-gyp@npm:^3.0.0": version: 3.0.0 resolution: "@npmcli/node-gyp@npm:3.0.0" @@ -3116,25 +3153,40 @@ __metadata: languageName: node linkType: hard -"@npmcli/promise-spawn@npm:^6.0.0": - version: 6.0.2 - resolution: "@npmcli/promise-spawn@npm:6.0.2" +"@npmcli/package-json@npm:^5.0.0": + version: 5.0.0 + resolution: "@npmcli/package-json@npm:5.0.0" dependencies: - which: ^3.0.0 - checksum: aa725780c13e1f97ab32ed7bcb5a207a3fb988e1d7ecdc3d22a549a22c8034740366b351c4dde4b011bcffcd8c4a7be6083d9cf7bc7e897b88837150de018528 + "@npmcli/git": ^5.0.0 + glob: ^10.2.2 + hosted-git-info: ^7.0.0 + json-parse-even-better-errors: ^3.0.0 + normalize-package-data: ^6.0.0 + proc-log: ^3.0.0 + semver: ^7.5.3 + checksum: 0d128e84e05e8a1771c8cc1f4232053fecf32e28f44e123ad16366ca3a7fd06f272f25f0b7d058f2763cab26bc479c8fc3c570af5de6324b05cb39868dcc6264 languageName: node linkType: hard -"@npmcli/run-script@npm:^6.0.0": - version: 6.0.2 - resolution: "@npmcli/run-script@npm:6.0.2" +"@npmcli/promise-spawn@npm:^7.0.0": + version: 7.0.1 + resolution: "@npmcli/promise-spawn@npm:7.0.1" + dependencies: + which: ^4.0.0 + checksum: a2b25d66d4dc835c69593bdf56588d66299fde3e80be4978347e686f24647007b794ce4da4cfcfcc569c67112720b746c4e7bf18ce45c096712d8b75fed19ec7 + languageName: node + linkType: hard + +"@npmcli/run-script@npm:7.0.4": + version: 7.0.4 + resolution: "@npmcli/run-script@npm:7.0.4" dependencies: "@npmcli/node-gyp": ^3.0.0 - "@npmcli/promise-spawn": ^6.0.0 - node-gyp: ^9.0.0 - read-package-json-fast: ^3.0.0 - which: ^3.0.0 - checksum: 7a671d7dbeae376496e1c6242f02384928617dc66cd22881b2387272205c3668f8490ec2da4ad63e1abf979efdd2bdf4ea0926601d78578e07d83cfb233b3a1a + "@npmcli/package-json": ^5.0.0 + "@npmcli/promise-spawn": ^7.0.0 + node-gyp: ^10.0.0 + which: ^4.0.0 + checksum: c44d6874cffb0a2f6d947e230083b605b6f253450e24aa185ef28391dc366b10807cd4ca113fe367057b8b5310add36391894f9d782af15424830658ee386dfb languageName: node linkType: hard @@ -3255,13 +3307,6 @@ __metadata: languageName: node linkType: hard -"@tootallnate/once@npm:2": - version: 2.0.0 - resolution: "@tootallnate/once@npm:2.0.0" - checksum: ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8 - languageName: node - linkType: hard - "@tsconfig/node10@npm:^1.0.7": version: 1.0.9 resolution: "@tsconfig/node10@npm:1.0.9" @@ -3331,12 +3376,12 @@ __metadata: languageName: node linkType: hard -"@types/bn.js@npm:^5.1.0": - version: 5.1.1 - resolution: "@types/bn.js@npm:5.1.1" +"@types/bn.js@npm:^5.1.0, @types/bn.js@npm:^5.1.5": + version: 5.1.5 + resolution: "@types/bn.js@npm:5.1.5" dependencies: "@types/node": "*" - checksum: e50ed2dd3abe997e047caf90e0352c71e54fc388679735217978b4ceb7e336e51477791b715f49fd77195ac26dd296c7bad08a3be9750e235f9b2e1edb1b51c2 + checksum: c87b28c4af74545624f8a3dae5294b16aa190c222626e8d4b2e327b33b1a3f1eeb43e7a24d914a9774bca43d8cd6e1cb0325c1f4b3a244af6693a024e1d918e6 languageName: node linkType: hard @@ -3817,10 +3862,10 @@ __metadata: languageName: node linkType: hard -"abbrev@npm:^1.0.0": - version: 1.1.1 - resolution: "abbrev@npm:1.1.1" - checksum: a4a97ec07d7ea112c517036882b2ac22f3109b7b19077dc656316d07d308438aac28e4d9746dc4d84bf6b1e75b4a7b0a5f3cb30592419f128ca9a8cee3bcfa17 +"abbrev@npm:^2.0.0": + version: 2.0.0 + resolution: "abbrev@npm:2.0.0" + checksum: 0e994ad2aa6575f94670d8a2149afe94465de9cedaaaac364e7fb43a40c3691c980ff74899f682f4ca58fa96b4cbd7421a015d3a6defe43a442117d7821a2f36 languageName: node linkType: hard @@ -3891,7 +3936,7 @@ __metadata: languageName: node linkType: hard -"agent-base@npm:6, agent-base@npm:^6.0.2": +"agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" dependencies: @@ -3900,14 +3945,12 @@ __metadata: languageName: node linkType: hard -"agentkeepalive@npm:^4.2.1": - version: 4.3.0 - resolution: "agentkeepalive@npm:4.3.0" +"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0": + version: 7.1.0 + resolution: "agent-base@npm:7.1.0" dependencies: - debug: ^4.1.0 - depd: ^2.0.0 - humanize-ms: ^1.2.1 - checksum: 982453aa44c11a06826c836025e5162c846e1200adb56f2d075400da7d32d87021b3b0a58768d949d824811f5654223d5a8a3dad120921a2439625eb847c6260 + debug: ^4.3.4 + checksum: f7828f991470a0cc22cb579c86a18cbae83d8a3cbed39992ab34fc7217c4d126017f1c74d0ab66be87f71455318a8ea3e757d6a37881b8d0f2a2c6aa55e5418f languageName: node linkType: hard @@ -4049,23 +4092,6 @@ __metadata: languageName: node linkType: hard -"aproba@npm:^1.0.3 || ^2.0.0": - version: 2.0.0 - resolution: "aproba@npm:2.0.0" - checksum: 5615cadcfb45289eea63f8afd064ab656006361020e1735112e346593856f87435e02d8dcc7ff0d11928bc7d425f27bc7c2a84f6c0b35ab0ff659c814c138a24 - languageName: node - linkType: hard - -"are-we-there-yet@npm:^3.0.0": - version: 3.0.1 - resolution: "are-we-there-yet@npm:3.0.1" - dependencies: - delegates: ^1.0.0 - readable-stream: ^3.6.0 - checksum: 52590c24860fa7173bedeb69a4c05fb573473e860197f618b9a28432ee4379049336727ae3a1f9c4cb083114601c1140cee578376164d0e651217a9843f9fe83 - languageName: node - linkType: hard - "arg@npm:^4.1.0": version: 4.1.3 resolution: "arg@npm:4.1.3" @@ -4339,15 +4365,15 @@ __metadata: languageName: node linkType: hard -"bin-links@npm:4.0.1": - version: 4.0.1 - resolution: "bin-links@npm:4.0.1" +"bin-links@npm:4.0.3": + version: 4.0.3 + resolution: "bin-links@npm:4.0.3" dependencies: cmd-shim: ^6.0.0 npm-normalize-package-bin: ^3.0.0 read-cmd-shim: ^4.0.0 write-file-atomic: ^5.0.0 - checksum: a806561750039bcd7d4234efe5c0b8b7ba0ea8495086740b0da6395abe311e2cdb75f8324787354193f652d2ac5ab038c4ca926ed7bcc6ce9bc2001607741104 + checksum: 3b3ee22efc38d608479d51675c8958a841b8b55b8975342ce86f28ac4e0bb3aef46e9dbdde976c6dc1fe1bd2aa00d42e00869ad35b57ee6d868f39f662858911 languageName: node linkType: hard @@ -4584,23 +4610,23 @@ __metadata: languageName: node linkType: hard -"cacache@npm:^17.0.0": - version: 17.1.3 - resolution: "cacache@npm:17.1.3" +"cacache@npm:^18.0.0": + version: 18.0.2 + resolution: "cacache@npm:18.0.2" dependencies: "@npmcli/fs": ^3.1.0 fs-minipass: ^3.0.0 glob: ^10.2.2 - lru-cache: ^7.7.1 - minipass: ^5.0.0 - minipass-collect: ^1.0.2 + lru-cache: ^10.0.1 + minipass: ^7.0.3 + minipass-collect: ^2.0.1 minipass-flush: ^1.0.5 minipass-pipeline: ^1.2.4 p-map: ^4.0.0 ssri: ^10.0.0 tar: ^6.1.11 unique-filename: ^3.0.0 - checksum: 385756781e1e21af089160d89d7462b7ed9883c978e848c7075b90b73cb823680e66092d61513050164588387d2ca87dd6d910e28d64bc13a9ac82cd8580c796 + checksum: 0250df80e1ad0c828c956744850c5f742c24244e9deb5b7dc81bca90f8c10e011e132ecc58b64497cc1cad9a98968676147fb6575f4f94722f7619757b17a11b languageName: node linkType: hard @@ -4862,15 +4888,6 @@ __metadata: languageName: node linkType: hard -"color-support@npm:^1.1.3": - version: 1.1.3 - resolution: "color-support@npm:1.1.3" - bin: - color-support: bin.js - checksum: 9b7356817670b9a13a26ca5af1c21615463b500783b739b7634a0c2047c16cef4b2865d7576875c31c3cddf9dd621fa19285e628f20198b233a5cfdda6d0793b - languageName: node - linkType: hard - "combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" @@ -4927,13 +4944,6 @@ __metadata: languageName: node linkType: hard -"console-control-strings@npm:^1.1.0": - version: 1.1.0 - resolution: "console-control-strings@npm:1.1.0" - checksum: 8755d76787f94e6cf79ce4666f0c5519906d7f5b02d4b884cf41e11dcd759ed69c57da0670afd9236d229a46e0f9cf519db0cd829c6dca820bb5a5c3def584ed - languageName: node - linkType: hard - "convert-source-map@npm:^1.4.0, convert-source-map@npm:^1.6.0": version: 1.9.0 resolution: "convert-source-map@npm:1.9.0" @@ -5074,7 +5084,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -5205,13 +5215,6 @@ __metadata: languageName: node linkType: hard -"delegates@npm:^1.0.0": - version: 1.0.0 - resolution: "delegates@npm:1.0.0" - checksum: a51744d9b53c164ba9c0492471a1a2ffa0b6727451bdc89e31627fdf4adda9d51277cfcbfb20f0a6f08ccb3c436f341df3e92631a3440226d93a8971724771fd - languageName: node - linkType: hard - "depcheck@npm:^1.4.7": version: 1.4.7 resolution: "depcheck@npm:1.4.7" @@ -5245,13 +5248,6 @@ __metadata: languageName: node linkType: hard -"depd@npm:^2.0.0": - version: 2.0.0 - resolution: "depd@npm:2.0.0" - checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a - languageName: node - linkType: hard - "deps-regex@npm:^0.2.0": version: 0.2.0 resolution: "deps-regex@npm:0.2.0" @@ -6039,7 +6035,7 @@ __metadata: languageName: node linkType: hard -"ethereumjs-util@npm:^7.0.10, ethereumjs-util@npm:^7.0.8, ethereumjs-util@npm:^7.1.2": +"ethereumjs-util@npm:^7.0.8, ethereumjs-util@npm:^7.1.2": version: 7.1.5 resolution: "ethereumjs-util@npm:7.1.5" dependencies: @@ -6499,22 +6495,6 @@ __metadata: languageName: node linkType: hard -"gauge@npm:^4.0.3": - version: 4.0.4 - resolution: "gauge@npm:4.0.4" - dependencies: - aproba: ^1.0.3 || ^2.0.0 - color-support: ^1.1.3 - console-control-strings: ^1.1.0 - has-unicode: ^2.0.1 - signal-exit: ^3.0.7 - string-width: ^4.2.3 - strip-ansi: ^6.0.1 - wide-align: ^1.1.5 - checksum: 788b6bfe52f1dd8e263cda800c26ac0ca2ff6de0b6eee2fe0d9e3abf15e149b651bd27bf5226be10e6e3edb5c4e5d5985a5a1a98137e7a892f75eff76467ad2d - languageName: node - linkType: hard - "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -6611,18 +6591,18 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2": - version: 10.3.3 - resolution: "glob@npm:10.3.3" +"glob@npm:^10.2.2, glob@npm:^10.3.10": + version: 10.3.10 + resolution: "glob@npm:10.3.10" dependencies: foreground-child: ^3.1.0 - jackspeak: ^2.0.3 + jackspeak: ^2.3.5 minimatch: ^9.0.1 minipass: ^5.0.0 || ^6.0.2 || ^7.0.0 path-scurry: ^1.10.1 bin: - glob: dist/cjs/src/bin.js - checksum: 29190d3291f422da0cb40b77a72fc8d2c51a36524e99b8bf412548b7676a6627489528b57250429612b6eec2e6fe7826d328451d3e694a9d15e575389308ec53 + glob: dist/esm/bin.mjs + checksum: 4f2fe2511e157b5a3f525a54092169a5f92405f24d2aed3142f4411df328baca13059f4182f1db1bf933e2c69c0bd89e57ae87edd8950cba8c7ccbe84f721cf3 languageName: node linkType: hard @@ -6801,13 +6781,6 @@ __metadata: languageName: node linkType: hard -"has-unicode@npm:^2.0.1": - version: 2.0.1 - resolution: "has-unicode@npm:2.0.1" - checksum: 1eab07a7436512db0be40a710b29b5dc21fa04880b7f63c9980b706683127e3c1b57cb80ea96d47991bdae2dfe479604f6a1ba410106ee1046a41d1bd0814400 - languageName: node - linkType: hard - "has@npm:^1.0.3": version: 1.0.3 resolution: "has@npm:1.0.3" @@ -6879,6 +6852,15 @@ __metadata: languageName: node linkType: hard +"hosted-git-info@npm:^7.0.0": + version: 7.0.1 + resolution: "hosted-git-info@npm:7.0.1" + dependencies: + lru-cache: ^10.0.1 + checksum: be5280f0a20d6153b47e1ab578e09f5ae8ad734301b3ed7e547dc88a6814d7347a4888db1b4f9635cc738e3c0ef1fbff02272aba7d07c75d4c5a50ff8d618db6 + languageName: node + linkType: hard + "html-encoding-sniffer@npm:^2.0.1": version: 2.0.1 resolution: "html-encoding-sniffer@npm:2.0.1" @@ -6913,14 +6895,13 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^5.0.0": - version: 5.0.0 - resolution: "http-proxy-agent@npm:5.0.0" +"http-proxy-agent@npm:^7.0.0": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" dependencies: - "@tootallnate/once": 2 - agent-base: 6 - debug: 4 - checksum: e2ee1ff1656a131953839b2a19cd1f3a52d97c25ba87bd2559af6ae87114abf60971e498021f9b73f9fd78aea8876d1fb0d4656aac8a03c6caa9fc175f22b786 + agent-base: ^7.1.0 + debug: ^4.3.4 + checksum: 670858c8f8f3146db5889e1fa117630910101db601fff7d5a8aa637da0abedf68c899f03d3451cac2f83bcc4c3d2dabf339b3aa00ff8080571cceb02c3ce02f3 languageName: node linkType: hard @@ -6934,6 +6915,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^7.0.1": + version: 7.0.4 + resolution: "https-proxy-agent@npm:7.0.4" + dependencies: + agent-base: ^7.0.2 + debug: 4 + checksum: daaab857a967a2519ddc724f91edbbd388d766ff141b9025b629f92b9408fc83cee8a27e11a907aede392938e9c398e240d643e178408a59e4073539cde8cfe9 + languageName: node + linkType: hard + "human-signals@npm:^2.1.0": version: 2.1.0 resolution: "human-signals@npm:2.1.0" @@ -6955,15 +6946,6 @@ __metadata: languageName: node linkType: hard -"humanize-ms@npm:^1.2.1": - version: 1.2.1 - resolution: "humanize-ms@npm:1.2.1" - dependencies: - ms: ^2.0.0 - checksum: 9c7a74a2827f9294c009266c82031030eae811ca87b0da3dceb8d6071b9bde22c9f3daef0469c3c533cc67a97d8a167cd9fc0389350e5f415f61a79b171ded16 - languageName: node - linkType: hard - "iconv-lite@npm:0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" @@ -7090,10 +7072,13 @@ __metadata: languageName: node linkType: hard -"ip@npm:^2.0.0": - version: 2.0.0 - resolution: "ip@npm:2.0.0" - checksum: cfcfac6b873b701996d71ec82a7dd27ba92450afdb421e356f44044ed688df04567344c36cbacea7d01b1c39a4c732dc012570ebe9bebfb06f27314bca625349 +"ip-address@npm:^9.0.5": + version: 9.0.5 + resolution: "ip-address@npm:9.0.5" + dependencies: + jsbn: 1.1.0 + sprintf-js: ^1.1.3 + checksum: aa15f12cfd0ef5e38349744e3654bae649a34c3b10c77a674a167e99925d1549486c5b14730eebce9fea26f6db9d5e42097b00aa4f9f612e68c79121c71652dc languageName: node linkType: hard @@ -7476,6 +7461,13 @@ __metadata: languageName: node linkType: hard +"isexe@npm:^3.1.1": + version: 3.1.1 + resolution: "isexe@npm:3.1.1" + checksum: 7fe1931ee4e88eb5aa524cd3ceb8c882537bc3a81b02e438b240e47012eef49c86904d0f0e593ea7c3a9996d18d0f1f3be8d3eaa92333977b0c3a9d353d5563e + languageName: node + linkType: hard + "isomorphic-fetch@npm:^3.0.0": version: 3.0.0 resolution: "isomorphic-fetch@npm:3.0.0" @@ -7538,16 +7530,16 @@ __metadata: languageName: node linkType: hard -"jackspeak@npm:^2.0.3": - version: 2.2.1 - resolution: "jackspeak@npm:2.2.1" +"jackspeak@npm:^2.3.5": + version: 2.3.6 + resolution: "jackspeak@npm:2.3.6" dependencies: "@isaacs/cliui": ^8.0.2 "@pkgjs/parseargs": ^0.11.0 dependenciesMeta: "@pkgjs/parseargs": optional: true - checksum: e29291c0d0f280a063fa18fbd1e891ab8c2d7519fd34052c0ebde38538a15c603140d60c2c7f432375ff7ee4c5f1c10daa8b2ae19a97c3d4affe308c8360c1df + checksum: 57d43ad11eadc98cdfe7496612f6bbb5255ea69fe51ea431162db302c2a11011642f50cfad57288bd0aea78384a0612b16e131944ad8ecd09d619041c8531b54 languageName: node linkType: hard @@ -8213,6 +8205,13 @@ __metadata: languageName: node linkType: hard +"jsbn@npm:1.1.0": + version: 1.1.0 + resolution: "jsbn@npm:1.1.0" + checksum: 944f924f2bd67ad533b3850eee47603eed0f6ae425fd1ee8c760f477e8c34a05f144c1bd4f5a5dd1963141dc79a2c55f89ccc5ab77d039e7077f3ad196b64965 + languageName: node + linkType: hard + "jsdoc-type-pratt-parser@npm:~3.1.0": version: 3.1.0 resolution: "jsdoc-type-pratt-parser@npm:3.1.0" @@ -8493,6 +8492,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^10.0.1, lru-cache@npm:^9.1.1 || ^10.0.0": + version: 10.2.0 + resolution: "lru-cache@npm:10.2.0" + checksum: eee7ddda4a7475deac51ac81d7dd78709095c6fa46e8350dc2d22462559a1faa3b81ed931d5464b13d48cbd7e08b46100b6f768c76833912bc444b99c37e25db + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -8511,20 +8517,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^7.7.1": - version: 7.18.3 - resolution: "lru-cache@npm:7.18.3" - checksum: e550d772384709deea3f141af34b6d4fa392e2e418c1498c078de0ee63670f1f46f5eee746e8ef7e69e1c895af0d4224e62ee33e66a543a14763b0f2e74c1356 - languageName: node - linkType: hard - -"lru-cache@npm:^9.1.1 || ^10.0.0": - version: 10.0.0 - resolution: "lru-cache@npm:10.0.0" - checksum: 18f101675fe283bc09cda0ef1e3cc83781aeb8373b439f086f758d1d91b28730950db785999cd060d3c825a8571c03073e8c14512b6655af2188d623031baf50 - languageName: node - linkType: hard - "lunr@npm:^2.3.9": version: 2.3.9 resolution: "lunr@npm:2.3.9" @@ -8564,26 +8556,22 @@ __metadata: languageName: node linkType: hard -"make-fetch-happen@npm:^11.0.3": - version: 11.1.1 - resolution: "make-fetch-happen@npm:11.1.1" +"make-fetch-happen@npm:^13.0.0": + version: 13.0.0 + resolution: "make-fetch-happen@npm:13.0.0" dependencies: - agentkeepalive: ^4.2.1 - cacache: ^17.0.0 + "@npmcli/agent": ^2.0.0 + cacache: ^18.0.0 http-cache-semantics: ^4.1.1 - http-proxy-agent: ^5.0.0 - https-proxy-agent: ^5.0.0 is-lambda: ^1.0.1 - lru-cache: ^7.7.1 - minipass: ^5.0.0 + minipass: ^7.0.2 minipass-fetch: ^3.0.0 minipass-flush: ^1.0.5 minipass-pipeline: ^1.2.4 negotiator: ^0.6.3 promise-retry: ^2.0.1 - socks-proxy-agent: ^7.0.0 ssri: ^10.0.0 - checksum: 7268bf274a0f6dcf0343829489a4506603ff34bd0649c12058753900b0eb29191dce5dba12680719a5d0a983d3e57810f594a12f3c18494e93a1fbc6348a4540 + checksum: 7c7a6d381ce919dd83af398b66459a10e2fe8f4504f340d1d090d3fa3d1b0c93750220e1d898114c64467223504bd258612ba83efbc16f31b075cd56de24b4af languageName: node linkType: hard @@ -8725,12 +8713,12 @@ __metadata: languageName: node linkType: hard -"minipass-collect@npm:^1.0.2": - version: 1.0.2 - resolution: "minipass-collect@npm:1.0.2" +"minipass-collect@npm:^2.0.1": + version: 2.0.1 + resolution: "minipass-collect@npm:2.0.1" dependencies: - minipass: ^3.0.0 - checksum: 14df761028f3e47293aee72888f2657695ec66bd7d09cae7ad558da30415fdc4752bbfee66287dcc6fd5e6a2fa3466d6c484dc1cbd986525d9393b9523d97f10 + minipass: ^7.0.3 + checksum: b251bceea62090f67a6cced7a446a36f4cd61ee2d5cea9aee7fff79ba8030e416327a1c5aa2908dc22629d06214b46d88fdab8c51ac76bacbf5703851b5ad342 languageName: node linkType: hard @@ -8792,10 +8780,10 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0": - version: 7.0.2 - resolution: "minipass@npm:7.0.2" - checksum: 46776de732eb7cef2c7404a15fb28c41f5c54a22be50d47b03c605bf21f5c18d61a173c0a20b49a97e7a65f78d887245066410642551e45fffe04e9ac9e325bc +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3": + version: 7.0.4 + resolution: "minipass@npm:7.0.4" + checksum: 87585e258b9488caf2e7acea242fd7856bbe9a2c84a7807643513a338d66f368c7d518200ad7b70a508664d408aa000517647b2930c259a8b1f9f0984f344a21 languageName: node linkType: hard @@ -8832,7 +8820,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1": +"ms@npm:^2.1.1": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -8955,24 +8943,23 @@ __metadata: languageName: node linkType: hard -"node-gyp@npm:^9.0.0, node-gyp@npm:latest": - version: 9.4.0 - resolution: "node-gyp@npm:9.4.0" +"node-gyp@npm:^10.0.0, node-gyp@npm:latest": + version: 10.0.1 + resolution: "node-gyp@npm:10.0.1" dependencies: env-paths: ^2.2.0 exponential-backoff: ^3.1.1 - glob: ^7.1.4 + glob: ^10.3.10 graceful-fs: ^4.2.6 - make-fetch-happen: ^11.0.3 - nopt: ^6.0.0 - npmlog: ^6.0.0 - rimraf: ^3.0.2 + make-fetch-happen: ^13.0.0 + nopt: ^7.0.0 + proc-log: ^3.0.0 semver: ^7.3.5 tar: ^6.1.2 - which: ^2.0.2 + which: ^4.0.0 bin: node-gyp: bin/node-gyp.js - checksum: 78b404e2e0639d64e145845f7f5a3cb20c0520cdaf6dda2f6e025e9b644077202ea7de1232396ba5bde3fee84cdc79604feebe6ba3ec84d464c85d407bb5da99 + checksum: 60a74e66d364903ce02049966303a57f898521d139860ac82744a5fdd9f7b7b3b61f75f284f3bfe6e6add3b8f1871ce305a1d41f775c7482de837b50c792223f languageName: node linkType: hard @@ -9000,14 +8987,26 @@ __metadata: languageName: node linkType: hard -"nopt@npm:^6.0.0": - version: 6.0.0 - resolution: "nopt@npm:6.0.0" +"nopt@npm:^7.0.0": + version: 7.2.0 + resolution: "nopt@npm:7.2.0" dependencies: - abbrev: ^1.0.0 + abbrev: ^2.0.0 bin: nopt: bin/nopt.js - checksum: 82149371f8be0c4b9ec2f863cc6509a7fd0fa729929c009f3a58e4eb0c9e4cae9920e8f1f8eb46e7d032fec8fb01bede7f0f41a67eb3553b7b8e14fa53de1dac + checksum: a9c0f57fb8cb9cc82ae47192ca2b7ef00e199b9480eed202482c962d61b59a7fbe7541920b2a5839a97b42ee39e288c0aed770e38057a608d7f579389dfde410 + languageName: node + linkType: hard + +"normalize-package-data@npm:^6.0.0": + version: 6.0.0 + resolution: "normalize-package-data@npm:6.0.0" + dependencies: + hosted-git-info: ^7.0.0 + is-core-module: ^2.8.1 + semver: ^7.3.5 + validate-npm-package-license: ^3.0.4 + checksum: 741211a4354ba6d618caffa98f64e0e5ec9e5575bf3aefe47f4b68e662d65f9ba1b6b2d10640c16254763ed0879288155566138b5ffe384172352f6e969c1752 languageName: node linkType: hard @@ -9018,13 +9017,46 @@ __metadata: languageName: node linkType: hard -"npm-normalize-package-bin@npm:^3.0.0": +"npm-install-checks@npm:^6.0.0": + version: 6.3.0 + resolution: "npm-install-checks@npm:6.3.0" + dependencies: + semver: ^7.1.1 + checksum: 6c20dadb878a0d2f1f777405217b6b63af1299d0b43e556af9363ee6eefaa98a17dfb7b612a473a473e96faf7e789c58b221e0d8ffdc1d34903c4f71618df3b4 + languageName: node + linkType: hard + +"npm-normalize-package-bin@npm:3.0.1, npm-normalize-package-bin@npm:^3.0.0": version: 3.0.1 resolution: "npm-normalize-package-bin@npm:3.0.1" checksum: de416d720ab22137a36292ff8a333af499ea0933ef2320a8c6f56a73b0f0448227fec4db5c890d702e26d21d04f271415eab6580b5546456861cc0c19498a4bf languageName: node linkType: hard +"npm-package-arg@npm:^11.0.0": + version: 11.0.1 + resolution: "npm-package-arg@npm:11.0.1" + dependencies: + hosted-git-info: ^7.0.0 + proc-log: ^3.0.0 + semver: ^7.3.5 + validate-npm-package-name: ^5.0.0 + checksum: 60364504e04e34fc20b47ad192efc9181922bce0cb41fa81871b1b75748d8551725f61b2f9a2e3dffb1782d749a35313f5dc02c18c3987653990d486f223adf2 + languageName: node + linkType: hard + +"npm-pick-manifest@npm:^9.0.0": + version: 9.0.0 + resolution: "npm-pick-manifest@npm:9.0.0" + dependencies: + npm-install-checks: ^6.0.0 + npm-normalize-package-bin: ^3.0.0 + npm-package-arg: ^11.0.0 + semver: ^7.3.5 + checksum: a6f102f9e9e8feea69be3a65e492fef6319084a85fc4e40dc88a277a3aa675089cef13ab0436ed7916e97c7bbba8315633d818eb15402c3abfb0bddc1af08cc7 + languageName: node + linkType: hard + "npm-run-path@npm:^4.0.1": version: 4.0.1 resolution: "npm-run-path@npm:4.0.1" @@ -9043,18 +9075,6 @@ __metadata: languageName: node linkType: hard -"npmlog@npm:^6.0.0": - version: 6.0.2 - resolution: "npmlog@npm:6.0.2" - dependencies: - are-we-there-yet: ^3.0.0 - console-control-strings: ^1.1.0 - gauge: ^4.0.3 - set-blocking: ^2.0.0 - checksum: ae238cd264a1c3f22091cdd9e2b106f684297d3c184f1146984ecbe18aaa86343953f26b9520dedd1b1372bc0316905b736c1932d778dbeb1fcf5a1001390e2a - languageName: node - linkType: hard - "number-to-bn@npm:1.7.0": version: 1.7.0 resolution: "number-to-bn@npm:1.7.0" @@ -9517,6 +9537,13 @@ __metadata: languageName: node linkType: hard +"proc-log@npm:^3.0.0": + version: 3.0.0 + resolution: "proc-log@npm:3.0.0" + checksum: 02b64e1b3919e63df06f836b98d3af002b5cd92655cab18b5746e37374bfb73e03b84fe305454614b34c25b485cc687a9eebdccf0242cda8fda2475dd2c97e02 + languageName: node + linkType: hard + "process-nextick-args@npm:~2.0.0": version: 2.0.1 resolution: "process-nextick-args@npm:2.0.1" @@ -9531,6 +9558,13 @@ __metadata: languageName: node linkType: hard +"promise-inflight@npm:^1.0.1": + version: 1.0.1 + resolution: "promise-inflight@npm:1.0.1" + checksum: 22749483091d2c594261517f4f80e05226d4d5ecc1fc917e1886929da56e22b5718b7f2a75f3807e7a7d471bc3be2907fe92e6e8f373ddf5c64bae35b5af3981 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -9640,16 +9674,6 @@ __metadata: languageName: node linkType: hard -"read-package-json-fast@npm:^3.0.0": - version: 3.0.2 - resolution: "read-package-json-fast@npm:3.0.2" - dependencies: - json-parse-even-better-errors: ^3.0.0 - npm-normalize-package-bin: ^3.0.0 - checksum: 8d406869f045f1d76e2a99865a8fd1c1af9c1dc06200b94d2b07eef87ed734b22703a8d72e1cd36ea36cc48e22020bdd187f88243c7dd0563f72114d38c17072 - languageName: node - linkType: hard - "readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" @@ -9800,7 +9824,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.20.0, resolve@npm:^1.22.0, resolve@npm:^1.22.1, resolve@npm:^1.22.3, resolve@npm:^1.22.4": +"resolve@npm:1.22.8, resolve@npm:^1.20.0, resolve@npm:^1.22.0, resolve@npm:^1.22.1, resolve@npm:^1.22.3, resolve@npm:^1.22.4": version: 1.22.8 resolution: "resolve@npm:1.22.8" dependencies: @@ -9813,7 +9837,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.0#~builtin, resolve@patch:resolve@^1.22.1#~builtin, resolve@patch:resolve@^1.22.3#~builtin, resolve@patch:resolve@^1.22.4#~builtin": +"resolve@patch:resolve@1.22.8#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.0#~builtin, resolve@patch:resolve@^1.22.1#~builtin, resolve@patch:resolve@^1.22.3#~builtin, resolve@patch:resolve@^1.22.4#~builtin": version: 1.22.8 resolution: "resolve@patch:resolve@npm%3A1.22.8#~builtin::version=1.22.8&hash=c3c19d" dependencies: @@ -10009,14 +10033,14 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.x, semver@npm:^7.0.0, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.4": - version: 7.5.4 - resolution: "semver@npm:7.5.4" +"semver@npm:7.x, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4": + version: 7.6.0 + resolution: "semver@npm:7.6.0" dependencies: lru-cache: ^6.0.0 bin: semver: bin/semver.js - checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3 + checksum: 7427f05b70786c696640edc29fdd4bc33b2acf3bbe1740b955029044f80575fc664e1a512e4113c3af21e767154a94b4aa214bf6cd6e42a1f6dba5914e0b208c languageName: node linkType: hard @@ -10038,13 +10062,6 @@ __metadata: languageName: node linkType: hard -"set-blocking@npm:^2.0.0": - version: 2.0.0 - resolution: "set-blocking@npm:2.0.0" - checksum: 6e65a05f7cf7ebdf8b7c75b101e18c0b7e3dff4940d480efed8aad3a36a4005140b660fa1d804cb8bce911cac290441dc728084a30504d3516ac2ff7ad607b02 - languageName: node - linkType: hard - "set-function-name@npm:^2.0.0": version: 2.0.1 resolution: "set-function-name@npm:2.0.1" @@ -10204,24 +10221,24 @@ __metadata: languageName: node linkType: hard -"socks-proxy-agent@npm:^7.0.0": - version: 7.0.0 - resolution: "socks-proxy-agent@npm:7.0.0" +"socks-proxy-agent@npm:^8.0.1": + version: 8.0.2 + resolution: "socks-proxy-agent@npm:8.0.2" dependencies: - agent-base: ^6.0.2 - debug: ^4.3.3 - socks: ^2.6.2 - checksum: 720554370154cbc979e2e9ce6a6ec6ced205d02757d8f5d93fe95adae454fc187a5cbfc6b022afab850a5ce9b4c7d73e0f98e381879cf45f66317a4895953846 + agent-base: ^7.0.2 + debug: ^4.3.4 + socks: ^2.7.1 + checksum: 4fb165df08f1f380881dcd887b3cdfdc1aba3797c76c1e9f51d29048be6e494c5b06d68e7aea2e23df4572428f27a3ec22b3d7c75c570c5346507433899a4b6d languageName: node linkType: hard -"socks@npm:^2.6.2": - version: 2.7.1 - resolution: "socks@npm:2.7.1" +"socks@npm:^2.7.1": + version: 2.8.0 + resolution: "socks@npm:2.8.0" dependencies: - ip: ^2.0.0 + ip-address: ^9.0.5 smart-buffer: ^4.2.0 - checksum: 259d9e3e8e1c9809a7f5c32238c3d4d2a36b39b83851d0f573bfde5f21c4b1288417ce1af06af1452569cd1eb0841169afd4998f0e04ba04656f6b7f0e46d748 + checksum: b245081650c5fc112f0e10d2ee3976f5665d2191b9f86b181edd3c875d53d84a94bc173752d5be2651a450e3ef799fe7ec405dba3165890c08d9ac0b4ec1a487 languageName: node linkType: hard @@ -10280,6 +10297,16 @@ __metadata: languageName: node linkType: hard +"spdx-correct@npm:^3.0.0": + version: 3.2.0 + resolution: "spdx-correct@npm:3.2.0" + dependencies: + spdx-expression-parse: ^3.0.0 + spdx-license-ids: ^3.0.0 + checksum: e9ae98d22f69c88e7aff5b8778dc01c361ef635580e82d29e5c60a6533cc8f4d820803e67d7432581af0cc4fb49973125076ee3b90df191d153e223c004193b2 + languageName: node + linkType: hard + "spdx-exceptions@npm:^2.1.0": version: 2.3.0 resolution: "spdx-exceptions@npm:2.3.0" @@ -10287,7 +10314,7 @@ __metadata: languageName: node linkType: hard -"spdx-expression-parse@npm:^3.0.1": +"spdx-expression-parse@npm:^3.0.0, spdx-expression-parse@npm:^3.0.1": version: 3.0.1 resolution: "spdx-expression-parse@npm:3.0.1" dependencies: @@ -10304,6 +10331,13 @@ __metadata: languageName: node linkType: hard +"sprintf-js@npm:^1.1.3": + version: 1.1.3 + resolution: "sprintf-js@npm:1.1.3" + checksum: a3fdac7b49643875b70864a9d9b469d87a40dfeaf5d34d9d0c5b1cda5fd7d065531fcb43c76357d62254c57184a7b151954156563a4d6a747015cfb41021cad0 + languageName: node + linkType: hard + "sprintf-js@npm:~1.0.2": version: 1.0.3 resolution: "sprintf-js@npm:1.0.3" @@ -10358,7 +10392,7 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -11133,6 +11167,16 @@ __metadata: languageName: node linkType: hard +"validate-npm-package-license@npm:^3.0.4": + version: 3.0.4 + resolution: "validate-npm-package-license@npm:3.0.4" + dependencies: + spdx-correct: ^3.0.0 + spdx-expression-parse: ^3.0.0 + checksum: 35703ac889d419cf2aceef63daeadbe4e77227c39ab6287eeb6c1b36a746b364f50ba22e88591f5d017bc54685d8137bc2d328d0a896e4d3fd22093c0f32a9ad + languageName: node + linkType: hard + "validate-npm-package-name@npm:^5.0.0": version: 5.0.0 resolution: "validate-npm-package-name@npm:5.0.0" @@ -11317,7 +11361,7 @@ __metadata: languageName: node linkType: hard -"which@npm:^2.0.1, which@npm:^2.0.2": +"which@npm:^2.0.1": version: 2.0.2 resolution: "which@npm:2.0.2" dependencies: @@ -11339,12 +11383,14 @@ __metadata: languageName: node linkType: hard -"wide-align@npm:^1.1.5": - version: 1.1.5 - resolution: "wide-align@npm:1.1.5" +"which@npm:^4.0.0": + version: 4.0.0 + resolution: "which@npm:4.0.0" dependencies: - string-width: ^1.0.2 || 2 || 3 || 4 - checksum: d5fc37cd561f9daee3c80e03b92ed3e84d80dde3365a8767263d03dacfc8fa06b065ffe1df00d8c2a09f731482fcacae745abfbb478d4af36d0a891fad4834d3 + isexe: ^3.1.1 + bin: + node-which: bin/which.js + checksum: f17e84c042592c21e23c8195108cff18c64050b9efb8459589116999ea9da6dd1509e6a1bac3aeebefd137be00fabbb61b5c2bc0aa0f8526f32b58ee2f545651 languageName: node linkType: hard @@ -11515,33 +11561,33 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^16.2.0": - version: 16.2.0 - resolution: "yargs@npm:16.2.0" +"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.5.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" dependencies: - cliui: ^7.0.2 + cliui: ^8.0.1 escalade: ^3.1.1 get-caller-file: ^2.0.5 require-directory: ^2.1.1 - string-width: ^4.2.0 + string-width: ^4.2.3 y18n: ^5.0.5 - yargs-parser: ^20.2.2 - checksum: b14afbb51e3251a204d81937c86a7e9d4bdbf9a2bcee38226c900d00f522969ab675703bee2a6f99f8e20103f608382936034e64d921b74df82b63c07c5e8f59 + yargs-parser: ^21.1.1 + checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a languageName: node linkType: hard -"yargs@npm:^17.0.1, yargs@npm:^17.5.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": - version: 17.7.2 - resolution: "yargs@npm:17.7.2" +"yargs@npm:^16.2.0": + version: 16.2.0 + resolution: "yargs@npm:16.2.0" dependencies: - cliui: ^8.0.1 + cliui: ^7.0.2 escalade: ^3.1.1 get-caller-file: ^2.0.5 require-directory: ^2.1.1 - string-width: ^4.2.3 + string-width: ^4.2.0 y18n: ^5.0.5 - yargs-parser: ^21.1.1 - checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a + yargs-parser: ^20.2.2 + checksum: b14afbb51e3251a204d81937c86a7e9d4bdbf9a2bcee38226c900d00f522969ab675703bee2a6f99f8e20103f608382936034e64d921b74df82b63c07c5e8f59 languageName: node linkType: hard