From 58d2bd72761c1eb72f682abfe4b01ace4e1bb8f5 Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Fri, 9 Feb 2024 13:39:53 -0500 Subject: [PATCH 01/39] [token-detection-controller] Add tests for API changes from merge with `DetectTokensController` (#3867) ## Explanation - This PR adds unit tests for the token-detection-controller API changes implemented in #3775. - The following changes are covered in the new tests: - Don't detect if keyring-controller `isUnlocked` state is false. - Subscribe to `KeyringController:unlock`, `KeyringController:lock` events - Subscribe to `AccountsController:selectedAccountChange` event - Detect tokens using `@metamask/contract-metadata` static token list if on mainnet and `useTokenDetection` is false. - Call `trackMetaMetricsEvent` for every detected token. - The aim of this PR isn't to achieve 100% test coverage for token-detection-controller. That will be the goal of follow-up ticket #1615 ## References - Closes #3626 - Follows from: - https://github.com/MetaMask/core/pull/3775/ - https://github.com/MetaMask/core/pull/3690/ - Followed by #1615 ## Changelog N/A ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- packages/assets-controllers/jest.config.js | 8 +- .../src/TokenDetectionController.test.ts | 790 ++++++++++++++++-- 2 files changed, 705 insertions(+), 93 deletions(-) diff --git a/packages/assets-controllers/jest.config.js b/packages/assets-controllers/jest.config.js index 314fa1281f..fc28607153 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.93, + functions: 96.71, + lines: 97.34, + statements: 97.4, }, }, diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index ba6522cedb..f16e696c43 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -32,6 +32,7 @@ import type { TokenDetectionControllerMessenger, } from './TokenDetectionController'; import { + STATIC_MAINNET_TOKEN_LIST, TokenDetectionController, controllerName, } from './TokenDetectionController'; @@ -40,7 +41,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; @@ -189,6 +190,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,16 +290,12 @@ describe('TokenDetectionController', () => { selectedAddress, }, }, - async ({ - controller, - mockTokenListGetState, - mockAddDetectedTokens, - }) => { + async ({ controller, mockTokenListGetState, callActionSpy }) => { mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -256,7 +308,7 @@ describe('TokenDetectionController', () => { await controller.start(); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -281,16 +333,12 @@ describe('TokenDetectionController', () => { selectedAddress, }, }, - async ({ - controller, - mockTokenListGetState, - mockAddDetectedTokens, - }) => { + async ({ controller, mockTokenListGetState, callActionSpy }) => { mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -303,7 +351,7 @@ describe('TokenDetectionController', () => { await controller.start(); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -331,16 +379,12 @@ describe('TokenDetectionController', () => { selectedAddress, }, }, - async ({ - controller, - mockTokenListGetState, - mockAddDetectedTokens, - }) => { + async ({ controller, mockTokenListGetState, callActionSpy }) => { const tokenListState = { ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -354,7 +398,7 @@ describe('TokenDetectionController', () => { await controller.start(); tokenListState.tokenList[sampleTokenB.address] = { - name: sampleTokenB.name as string, + name: sampleTokenB.name, symbol: sampleTokenB.symbol, decimals: sampleTokenB.decimals, address: sampleTokenB.address, @@ -365,7 +409,7 @@ describe('TokenDetectionController', () => { mockTokenListGetState(tokenListState); await advanceTime({ clock, duration: interval }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA, sampleTokenB], { @@ -394,7 +438,7 @@ describe('TokenDetectionController', () => { controller, mockTokensGetState, mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, }) => { mockTokensGetState({ ...getDefaultTokensState(), @@ -404,7 +448,7 @@ describe('TokenDetectionController', () => { ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -417,7 +461,7 @@ describe('TokenDetectionController', () => { await controller.start(); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -436,16 +480,12 @@ describe('TokenDetectionController', () => { selectedAddress: '', }, }, - async ({ - controller, - mockTokenListGetState, - mockAddDetectedTokens, - }) => { + async ({ controller, mockTokenListGetState, callActionSpy }) => { mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -458,7 +498,7 @@ describe('TokenDetectionController', () => { await controller.start(); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -466,6 +506,222 @@ 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(), + tokenList: { + [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(), + tokenList: { + [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(), + tokenList: { + [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(), + tokenList: { + [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 +732,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,13 +753,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, @@ -521,7 +777,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -550,13 +806,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, @@ -581,7 +837,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -613,13 +869,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 +893,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -661,13 +917,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 +941,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 +1080,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 +1104,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -762,13 +1128,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 +1159,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -812,7 +1178,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,14 +1195,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, @@ -853,7 +1219,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -881,14 +1247,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, @@ -905,7 +1271,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -928,14 +1294,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 +1318,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 +1393,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 +1417,7 @@ describe('TokenDetectionController', () => { }); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -1020,7 +1436,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,14 +1453,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, @@ -1059,7 +1475,7 @@ describe('TokenDetectionController', () => { triggerTokenListStateChange(tokenListState); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -1087,7 +1503,7 @@ describe('TokenDetectionController', () => { }, async ({ mockTokenListGetState, - mockAddDetectedTokens, + callActionSpy, triggerTokenListStateChange, }) => { const tokenListState = { @@ -1099,15 +1515,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 +1588,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 +1610,7 @@ describe('TokenDetectionController', () => { triggerTokenListStateChange(tokenListState); await advanceTime({ clock, duration: 1 }); - expect(mockAddDetectedTokens).not.toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', ); }, @@ -1184,7 +1648,7 @@ describe('TokenDetectionController', () => { ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { - name: sampleTokenA.name as string, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -1232,11 +1696,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, + accountAddress: 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,14 +1752,61 @@ describe('TokenDetectionController', () => { }, async ({ controller, - mockTokenListGetState, - mockAddDetectedTokens, + triggerPreferencesStateChange, + callActionSpy, }) => { + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + await controller.detectTokens({ + networkClientId: NetworkType.mainnet, + accountAddress: 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, + name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, address: sampleTokenA.address, @@ -1271,7 +1822,7 @@ describe('TokenDetectionController', () => { accountAddress: selectedAddress, }); - expect(mockAddDetectedTokens).toHaveBeenCalledWith( + expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addDetectedTokens', [sampleTokenA], { @@ -1282,6 +1833,57 @@ 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(), + tokenList: { + [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, + }); + + expect(mockTrackMetaMetricsEvent).toHaveBeenCalledWith({ + event: 'Token Detected', + category: 'Wallet', + properties: { + tokens: [`${sampleTokenA.symbol} - ${sampleTokenA.address}`], + token_standard: 'ERC20', + asset_type: 'TOKEN', + }, + }); + }, + ); + }); }); }); @@ -1303,7 +1905,7 @@ type WithControllerCallback = ({ mockTokensGetState, mockTokenListGetState, mockPreferencesGetState, - mockAddDetectedTokens, + callActionSpy, triggerKeyringUnlock, triggerKeyringLock, triggerTokenListStateChange, @@ -1319,7 +1921,7 @@ type WithControllerCallback = ({ mockGetNetworkConfigurationByNetworkClientId: ( handler: (networkClientId: string) => NetworkConfiguration, ) => void; - mockAddDetectedTokens: jest.SpyInstance; + callActionSpy: jest.SpyInstance; triggerKeyringUnlock: () => void; triggerKeyringLock: () => void; triggerTokenListStateChange: (state: TokenListState) => void; @@ -1330,6 +1932,7 @@ type WithControllerCallback = ({ type WithControllerOptions = { options?: Partial[0]>; + isKeyringUnlocked?: boolean; messenger?: ControllerMessenger; }; @@ -1350,7 +1953,7 @@ 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(); @@ -1358,7 +1961,7 @@ async function withController( controllerMessenger.registerActionHandler( 'KeyringController:getState', mockKeyringState.mockReturnValue({ - isUnlocked: true, + isUnlocked: isKeyringUnlocked ?? true, } as KeyringControllerState), ); const mockGetNetworkConfigurationByNetworkClientId = jest.fn< @@ -1390,7 +1993,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, @@ -1421,7 +2033,7 @@ async function withController( handler, ); }, - mockAddDetectedTokens, + callActionSpy, triggerKeyringUnlock: () => { controllerMessenger.publish('KeyringController:unlock'); }, From 81ac379173cd530c006d5584aec255aa0e62b304 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Tue, 13 Feb 2024 09:55:05 +0100 Subject: [PATCH 02/39] Check pending txs before submitting cancel (#3800) ## Explanation This PR updates `speedUp` and `cancel` transactions to force checking pending transaction statuses before creating each transaction. ## References * Fixes https://github.com/MetaMask/metamask-extension/issues/22314 ## Changelog ### `@metamask/transaction-controller` - **Added**: Added a call of `PendingTransactionTracker.checkTransactions` before creating speed-up and cancel transactions ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: Matthew Walsh --- .../src/TransactionController.test.ts | 137 ++++++++++++++++++ .../src/TransactionController.ts | 38 ++++- .../helpers/PendingTransactionTracker.test.ts | 53 +++++++ .../src/helpers/PendingTransactionTracker.ts | 18 +++ 4 files changed, 244 insertions(+), 2 deletions(-) diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 346f316772..ce7f030a39 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -625,6 +625,7 @@ describe('TransactionController', () => { hub: { on: jest.fn(), }, + forceCheckTransaction: jest.fn(), } as unknown as jest.Mocked; incomingTransactionHelperClassMock.mockReturnValue( @@ -1878,6 +1879,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 +2166,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, diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 63a2c70cb2..369ba819e9 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -876,7 +876,7 @@ export class TransactionController extends BaseControllerV1< txParams: newTxParams, }); - const hash = await this.publishTransaction(rawTx); + const hash = await this.publishTransactionForRetry(rawTx, transactionMeta); const cancelTransactionMeta: TransactionMeta = { actionId, @@ -1028,7 +1028,7 @@ export class TransactionController extends BaseControllerV1< log('Submitting speed up transaction', { oldFee, newFee, txParams }); - const hash = await query(this.ethQuery, 'sendRawTransaction', [rawTx]); + const hash = await this.publishTransactionForRetry(rawTx, transactionMeta); const baseTransactionMeta: TransactionMeta = { ...transactionMeta, @@ -2747,4 +2747,38 @@ export class TransactionController extends BaseControllerV1< log('Error while updating post transaction balance', error); } } + + private async publishTransactionForRetry( + rawTx: string, + transactionMeta: TransactionMeta, + ): Promise { + try { + const hash = await this.publishTransaction(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') + ); + } } diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts index 2f56ceebaa..71bfb86be4 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts @@ -822,4 +822,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..ae48ba4c48 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -144,6 +144,24 @@ export class PendingTransactionTracker { }); } + /** + * 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 nonceGlobalLock = await this.#nonceTracker.getGlobalLock(); + + try { + await this.#checkTransaction(txMeta); + } catch (error) { + /* istanbul ignore next */ + log('Failed to check transaction', error); + } finally { + nonceGlobalLock.releaseLock(); + } + } + #start() { if (this.#running) { return; From 78cabd02b3ea5f427a0d69e5a8bacc903171f98f Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Tue, 13 Feb 2024 11:27:41 +0100 Subject: [PATCH 03/39] Remove `cancelMultiplier` & `speedUpMultiplier` option from `TransactionController` (#3909) ## Explanation This PR aims to remove `cancelMultiplier` option from constructor options of `TransactionController`. ## References Fixes: https://github.com/MetaMask/MetaMask-planning/issues/1929 ## Changelog ### `@metamask/transaction-controller` - **REMOVED**: The `cancelMultiplier` & `speedUpMultiplier` parameters has been eliminated from the `TransactionController` constructor. Its value is now set as a hardcoded global constant of `1.1`. ## Checklist - [X] I've updated the test suite for new or updated code as appropriate - [X] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../src/TransactionController.ts | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 369ba819e9..ec80c8a8ac 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -176,7 +176,7 @@ 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 @@ -269,10 +269,6 @@ export class TransactionController extends BaseControllerV1< private readonly pendingTransactionTracker: PendingTransactionTracker; - private readonly cancelMultiplier: number; - - private readonly speedUpMultiplier: number; - private readonly signAbortCallbacks: Map void> = new Map(); private readonly afterSign: ( @@ -352,7 +348,6 @@ export class TransactionController extends BaseControllerV1< * * @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. @@ -375,7 +370,6 @@ export class TransactionController extends BaseControllerV1< * @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. @@ -389,7 +383,6 @@ export class TransactionController extends BaseControllerV1< constructor( { blockTracker, - cancelMultiplier, disableHistory, disableSendFlowHistory, disableSwaps, @@ -407,11 +400,9 @@ export class TransactionController extends BaseControllerV1< pendingTransactions = {}, provider, securityProviderRequest, - speedUpMultiplier, hooks = {}, }: { blockTracker: BlockTracker; - cancelMultiplier?: number; disableHistory: boolean; disableSendFlowHistory: boolean; disableSwaps: boolean; @@ -438,7 +429,6 @@ export class TransactionController extends BaseControllerV1< }; provider: Provider; securityProviderRequest?: SecurityProviderRequest; - speedUpMultiplier?: number; hooks: { afterSign?: ( transactionMeta: TransactionMeta, @@ -495,8 +485,6 @@ export class TransactionController extends BaseControllerV1< this.getExternalPendingTransactions = getExternalPendingTransactions ?? (() => []); this.securityProviderRequest = securityProviderRequest; - this.cancelMultiplier = cancelMultiplier ?? CANCEL_RATE; - this.speedUpMultiplier = speedUpMultiplier ?? SPEED_UP_RATE; this.afterSign = hooks?.afterSign ?? (() => true); this.beforeApproveOnInit = hooks?.beforeApproveOnInit ?? (() => true); @@ -794,7 +782,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 +796,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 +810,7 @@ export class TransactionController extends BaseControllerV1< transactionMeta.txParams?.maxPriorityFeePerGas; const minMaxPriorityFeePerGas = getIncreasedPriceFromExisting( existingMaxPriorityFeePerGas, - this.cancelMultiplier, + CANCEL_RATE, ); const maxPriorityFeePerGasValues = isFeeMarketEIP1559Values(gasValues) && gasValues.maxPriorityFeePerGas; @@ -955,7 +943,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 +957,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 +971,7 @@ export class TransactionController extends BaseControllerV1< transactionMeta.txParams?.maxPriorityFeePerGas; const minMaxPriorityFeePerGas = getIncreasedPriceFromExisting( existingMaxPriorityFeePerGas, - this.speedUpMultiplier, + SPEED_UP_RATE, ); const maxPriorityFeePerGasValues = isFeeMarketEIP1559Values(gasValues) && gasValues.maxPriorityFeePerGas; From 464f899d678087ac142bdeb5d7b4989e5e512b52 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 15 Feb 2024 14:01:43 +0100 Subject: [PATCH 04/39] fix: fix import ERC20 on network 1337 (#3777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation This PR fixes importing an ERC20 token on chainId 1337. This issue is on extension and Mobile. When you run a local ganache node, you use testDapp to deploy a token, then you try to add the token to your wallet; and go back to home page but you wont be able to see the token. The error starts on this [line](https://github.com/MetaMask/core/blob/f8437e9a626007de7192871452273ebd0f2edb35/packages/assets-controllers/src/TokensController.ts#L335). It looks like it fails to pass that line to create the `newEntry` and push it to `newTokens` array. The error is thrown on this [line](https://github.com/MetaMask/core/blob/f8437e9a626007de7192871452273ebd0f2edb35/packages/assets-controllers/src/token-service.ts#L136) (See screenshot) Screenshot 2024-01-12 at 18 14 32 I see that [tokenApi](https://github.com/consensys-vertical-apps/va-mmcx-token-api) does not support chainId 1337 and the error is being thrown here https://github.com/consensys-vertical-apps/va-mmcx-token-api/blob/6c1b749c33a110f9dd8004dd283f6d9f7dcb4824/src/server.ts#L522 that is because [networkConfig](https://github.com/consensys-vertical-apps/va-mmcx-token-api/blob/6c1b749c33a110f9dd8004dd283f6d9f7dcb4824/src/constants.ts#L399) does not contain the 1337 chainId. After testing also with Goerli testnet, where importing ERC20 works fine, i noticed that goerli is not supported on core so the fetchTokenMetadata threw [here](https://github.com/MetaMask/core/blob/f8437e9a626007de7192871452273ebd0f2edb35/packages/assets-controllers/src/token-service.ts#L82) because `isTokenDetectionSupportedForNetwork` returned false https://github.com/MetaMask/core/blob/f8437e9a626007de7192871452273ebd0f2edb35/packages/assets-controllers/src/assetsUtil.ts#L156. I wasnt sure why we did not treat chainId 1337 same way as goerli. So i removed the `|| chainId === GANACHE_CHAIN_ID` check on the utils fct. After the fix, i tested importing the ERC20, importing ERC721/1155, works fine. Lemme know if anything! 🙏 ## Video Before https://github.com/MetaMask/core/assets/10994169/7b567366-f5df-4d1b-b61d-7d4a46711faa ## Video After https://github.com/MetaMask/core/assets/10994169/3068a526-4c95-4c88-9c62-38acb202561f ## References * Fixes https://github.com/MetaMask/metamask-mobile/issues/6410 ## Changelog ### `@metamask/assets-controllers` - ****: Updates the fct on assetsUtils `isTokenListSupportedForNetwork` and removed the check for `GANACHE_CHAIN_ID` ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- packages/assets-controllers/src/assetsUtil.test.ts | 4 ++-- packages/assets-controllers/src/assetsUtil.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) 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..a36b681f46 100644 --- a/packages/assets-controllers/src/assetsUtil.ts +++ b/packages/assets-controllers/src/assetsUtil.ts @@ -1,7 +1,6 @@ import type { BigNumber } from '@ethersproject/bignumber'; import { convertHexToDecimal, - GANACHE_CHAIN_ID, toChecksumHexAddress, } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; @@ -152,9 +151,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); } /** From 3ea7a48ba7e037846d712cf3fb62e0a8b7689dce Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Thu, 15 Feb 2024 08:16:04 -0800 Subject: [PATCH 05/39] fix: NFT detection running too many times (#3917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation The `NftDetectionController` was making unnecessary HTTP requests. (1) It was fetching NFTs when any setting changes: https://github.com/MetaMask/core/assets/3500406/297b1889-ee17-4c57-890b-8f94e4b1be4b (2) When each polling interval arrives, it can send ~7 identical requests instead of 1. See timeline: Screenshot 2024-02-13 at 6 08 53 PM (3) one redundant request when switching accounts All come from `onPreferencesStateChange` triggering a re-start. (2) happens because it fires ~7 times on startup from keyring related updates. This causes each to run: ``` this.stopPolling(); await this.detectNfts(); this.intervalId = setInterval(...) ``` Because `detectNfts` is async, `intervalId` is still undefined. So they each schedule their own interval without stopping a previous interval. Fix: Only start/stop when relevant settings change. ## References ## Changelog ### `@metamask/assets-controllers` - **FIXED**: NFT detection running too many times ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../src/NftDetectionController.test.ts | 28 +++++++++++++++++++ .../src/NftDetectionController.ts | 3 -- 2 files changed, 28 insertions(+), 3 deletions(-) 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 { From 67a602d53cebfefb6ecbf558a38556c83e939f05 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Fri, 16 Feb 2024 01:43:33 +0800 Subject: [PATCH 06/39] fix: add wildcard for custody keyring (#3899) ## Explanation This pr resolves the special case when passing a custody keyring type to the function keyringTypeToName. ## References Fixes: https://github.com/MetaMask/accounts-planning/issues/233 ## Changelog ### `@metamask/keyring-controller` - **CHANGED**: Custody keyring type. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> --- .../src/AccountsController.test.ts | 2 +- packages/accounts-controller/src/utils.ts | 11 +++++++---- .../src/KeyringController.test.ts | 15 +++++++++++++++ .../keyring-controller/src/KeyringController.ts | 11 ++++++++++- 4 files changed, 33 insertions(+), 6 deletions(-) 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/utils.ts b/packages/accounts-controller/src/utils.ts index be620a73ac..3a599c0970 100644 --- a/packages/accounts-controller/src/utils.ts +++ b/packages/accounts-controller/src/utils.ts @@ -1,4 +1,4 @@ -import { KeyringTypes } from '@metamask/keyring-controller'; +import { isCustodyKeyring, KeyringTypes } from '@metamask/keyring-controller'; import { sha256FromString } from 'ethereumjs-util'; import { v4 as uuid } from 'uuid'; @@ -9,6 +9,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 +37,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}`); } diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 9c5118b37e..c10b793a72 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -43,6 +43,7 @@ import { AccountImportStrategy, KeyringController, KeyringTypes, + isCustodyKeyring, keyringBuilderFactory, } from './KeyringController'; @@ -2791,6 +2792,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 diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 2ead832949..068f6b7a84 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -61,9 +61,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 * From 5cceb682f0ebd92465af9c689746805f48f86851 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Thu, 15 Feb 2024 21:45:09 +0100 Subject: [PATCH 07/39] Release 116.0.0 (#3915) Co-authored-by: Elliot Winkler --- package.json | 2 +- packages/address-book-controller/package.json | 2 +- packages/assets-controllers/package.json | 2 +- packages/controller-utils/CHANGELOG.md | 9 +++- packages/controller-utils/package.json | 2 +- packages/ens-controller/package.json | 2 +- packages/gas-fee-controller/CHANGELOG.md | 10 ++++- packages/gas-fee-controller/package.json | 4 +- packages/logging-controller/package.json | 2 +- packages/message-manager/package.json | 2 +- packages/network-controller/package.json | 2 +- packages/permission-controller/package.json | 2 +- packages/phishing-controller/package.json | 2 +- packages/polling-controller/package.json | 2 +- packages/preferences-controller/package.json | 2 +- .../queued-request-controller/package.json | 2 +- packages/signature-controller/package.json | 2 +- packages/transaction-controller/CHANGELOG.md | 21 ++++++++- packages/transaction-controller/package.json | 6 +-- .../user-operation-controller/package.json | 8 ++-- yarn.lock | 44 +++++++++---------- 21 files changed, 82 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index 5388270499..f337c35e8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "115.0.0", + "version": "116.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/address-book-controller/package.json b/packages/address-book-controller/package.json index 27c869ce64..183fee7959 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/assets-controllers/package.json b/packages/assets-controllers/package.json index 8ee5303d60..a92c4b7f79 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -40,7 +40,7 @@ "@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", diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 49b1f7effd..93094f9a8e 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.0.3] + +### Changed + +- Bump `@metamask/ethjs-unit` to `^0.3.0` ([#3897](https://github.com/MetaMask/core/pull/3897)) + ## [8.0.2] ### Changed @@ -264,7 +270,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/package.json b/packages/controller-utils/package.json index e850b33aea..9acfe9eaa4 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", diff --git a/packages/ens-controller/package.json b/packages/ens-controller/package.json index b075a73f41..6d549e6e9e 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 157ccf70ca..b790744e2c 100644 --- a/packages/gas-fee-controller/CHANGELOG.md +++ b/packages/gas-fee-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -201,7 +208,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 c6215bd3fc..853c9bcb01 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,7 +32,7 @@ }, "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", diff --git a/packages/logging-controller/package.json b/packages/logging-controller/package.json index 8b0b074dd9..231a5389c9 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 d3e8c997e8..f6f87932a0 100644 --- a/packages/message-manager/package.json +++ b/packages/message-manager/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/eth-sig-util": "^7.0.1", "@metamask/utils": "^8.3.0", "@types/uuid": "^8.3.0", diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 57ea5cde28..cf95d9d126 100644 --- a/packages/network-controller/package.json +++ b/packages/network-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/eth-json-rpc-infura": "^9.0.0", "@metamask/eth-json-rpc-middleware": "^12.1.0", "@metamask/eth-json-rpc-provider": "^2.3.2", diff --git a/packages/permission-controller/package.json b/packages/permission-controller/package.json index 7540583309..de7e663cfc 100644 --- a/packages/permission-controller/package.json +++ b/packages/permission-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/json-rpc-engine": "^7.3.2", "@metamask/rpc-errors": "^6.1.0", "@metamask/utils": "^8.3.0", diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 48ff7d7a2a..e9b5ac5c70 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 624354b2f2..06591d8e21 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/package.json b/packages/preferences-controller/package.json index e2dce319d6..0bb1f968e7 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/queued-request-controller/package.json b/packages/queued-request-controller/package.json index d3e733b2f4..4ac47bda6f 100644 --- a/packages/queued-request-controller/package.json +++ b/packages/queued-request-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/json-rpc-engine": "^7.3.2", "@metamask/rpc-errors": "^6.1.0", "@metamask/swappable-obj-proxy": "^2.2.0", diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index fb3fbfd143..1d096ecead 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -33,7 +33,7 @@ "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", diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index e685f7fc5e..94cee037a4 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -477,7 +495,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@21.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@22.0.0...HEAD +[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/package.json b/packages/transaction-controller/package.json index 6e6580df6e..48cd497dff 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": "22.0.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", @@ -36,9 +36,9 @@ "@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", diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 21d2b9faa7..2e7885f36b 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -34,14 +34,14 @@ "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/transaction-controller": "^22.0.0", "@metamask/utils": "^8.3.0", "ethereumjs-util": "^7.0.10", "immer": "^9.0.6", @@ -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": "^22.0.0" }, "engines": { "node": ">=16.0.0" diff --git a/yarn.lock b/yarn.lock index c2b4cab67d..b03168c257 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1524,7 +1524,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 @@ -1586,7 +1586,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 @@ -1727,7 +1727,7 @@ __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: @@ -1828,7 +1828,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,13 +2109,13 @@ __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 @@ -2268,7 +2268,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,7 +2286,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/eth-sig-util": ^7.0.1 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2335,7 +2335,7 @@ __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 @@ -2438,7 +2438,7 @@ __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/rpc-errors": ^6.1.0 "@metamask/utils": ^8.3.0 @@ -2485,7 +2485,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 +2507,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 +2542,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,7 +2584,7 @@ __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 @@ -2693,7 +2693,7 @@ __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 @@ -2889,7 +2889,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@^21.2.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@^22.0.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -2900,10 +2900,10 @@ __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/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 @@ -2939,14 +2939,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/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/transaction-controller": ^22.0.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 @@ -2965,7 +2965,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": ^22.0.0 languageName: unknown linkType: soft From 1fcddc8ef5e75119a8af1dee989bc71cc39e6d2b Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 15 Feb 2024 15:57:51 -0500 Subject: [PATCH 08/39] TransactionController MultiChain Refactor (#3643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation Resolves: https://github.com/MetaMask/MetaMask-planning/issues/1019 ## Changelog ### `@metamask/transaction-controller` ### Added - **BREAKING:** Constructor now expects a `getNetworkClientRegistry` callback function - **BREAKING:** Messenger now requires `NetworkController:stateChange` to be an allowed event - **BREAKING:** Messenger now requires `NetworkController:findNetworkClientByChainId` and `NetworkController:getNetworkClientById` actions - Adds a feature flag parameter `isMultichainEnabled ` passed via the constructor (and defaulted to false), which when passed a truthy value will initialize `incomingTransactionHelper`, `pendingTransactionTracker`, `nonceTracker` per networkClientId from the NetworkController’s `networkClientRegistry` and a `EtherScanRemoteTransactionSource` helper per chainId in the registry. - Adds `destroy()` method that stops/removes internal polling and listeners - Exports `PendingTransactionOptions` type - Exports `TransactionControllerOptions` type - Adds `stopAllIncomingTransactionPolling()` that stops the global IncomingTransactionHelper and each networkClientId's IncomingTransactionHelper ### Changed - **BREAKING:** `approveTransactionsWithSameNonce()` now requires `chainId` to be populated in for each TransactionParams that is passed - `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 - `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 - `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 - `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. - When used with the `enableMultichain` 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 - Approval of transactions previously used the chainId from the global provider state as the source of truth. Instead it will now use the chainId from the TransactionMeta object. **Not sure we really need to put this in the changelog since all tx will have chainId populated** - `EtherscanRemoteTransactionSource` now enforces a 5 second delay between requests to avoid rate limiting - `TransactionMeta` type now specifies an optional `networkClientId` field - `startIncomingTransactionPolling()` now accepts an optional array of networkClientIds. When provided, the IncomingTransactionHelpers for those networkClientIds will be started. If empty or not provided, the global IncomingTransactionHelper will be started. - `stopIncomingTransactionPolling()` now accepts an optional array of networkClientIds. When provided, the IncomingTransactionHelpers for those networkClientIds will be stoppped. If empty or not provided, the global IncomingTransactionHelper will be stopped. ### Removed ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: Jiexi Luan Co-authored-by: Alex Donesky Co-authored-by: Matthew Walsh Co-authored-by: Elliot Winkler --- .../transaction-controller/jest.config.js | 4 +- packages/transaction-controller/package.json | 1 + .../src/TransactionController.test.ts | 588 +++-- .../src/TransactionController.ts | 843 ++++--- .../TransactionControllerIntegration.test.ts | 1936 +++++++++++++++++ .../EtherscanRemoteTransactionSource.test.ts | 146 +- .../EtherscanRemoteTransactionSource.ts | 37 +- .../helpers/IncomingTransactionHelper.test.ts | 27 +- .../src/helpers/IncomingTransactionHelper.ts | 60 +- .../helpers/MultichainTrackingHelper.test.ts | 869 ++++++++ .../src/helpers/MultichainTrackingHelper.ts | 454 ++++ .../helpers/PendingTransactionTracker.test.ts | 227 +- .../src/helpers/PendingTransactionTracker.ts | 58 +- packages/transaction-controller/src/types.ts | 6 + .../src/utils/etherscan.test.ts | 16 + .../src/utils/etherscan.ts | 27 +- .../src/utils/gas-fees.ts | 20 +- .../src/utils/gas.test.ts | 15 +- .../transaction-controller/src/utils/gas.ts | 16 +- .../src/utils/nonce.test.ts | 34 +- .../transaction-controller/src/utils/nonce.ts | 8 +- .../test/EtherscanMocks.ts | 134 ++ .../test/JsonRpcRequestMocks.ts | 230 ++ tests/mock-network.ts | 2 +- yarn.lock | 1 + 25 files changed, 5001 insertions(+), 758 deletions(-) create mode 100644 packages/transaction-controller/src/TransactionControllerIntegration.test.ts create mode 100644 packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts create mode 100644 packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts create mode 100644 packages/transaction-controller/test/EtherscanMocks.ts create mode 100644 packages/transaction-controller/test/JsonRpcRequestMocks.ts diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js index 3679c698b0..eeeef9619a 100644 --- a/packages/transaction-controller/jest.config.js +++ b/packages/transaction-controller/jest.config.js @@ -19,8 +19,8 @@ module.exports = merge(baseConfig, { global: { branches: 89.05, functions: 93.89, - lines: 97.85, - statements: 97.81, + lines: 97.73, + statements: 97.76, }, }, diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 48cd497dff..553e20ed2b 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -59,6 +59,7 @@ "@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 ce7f030a39..2df7c26044 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'; @@ -27,6 +30,7 @@ import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import { flushPromises } from '../../../tests/helpers'; import { mockNetwork } from '../../../tests/mock-network'; import { IncomingTransactionHelper } from './helpers/IncomingTransactionHelper'; +import { MultichainTrackingHelper } from './helpers/MultichainTrackingHelper'; import { PendingTransactionTracker } from './helpers/PendingTransactionTracker'; import type { TransactionControllerMessenger, @@ -197,6 +201,7 @@ jest.mock('@metamask/eth-query', () => jest.mock('./helpers/IncomingTransactionHelper'); jest.mock('./helpers/PendingTransactionTracker'); +jest.mock('./helpers/MultichainTrackingHelper'); /** * Builds a mock block tracker with a canned block number that can be used in @@ -224,23 +229,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 +276,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; @@ -485,14 +531,13 @@ describe('TransactionController', () => { 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 timeCounter = 0; const incomingTransactionHelperClassMock = @@ -505,6 +550,11 @@ describe('TransactionController', () => { typeof PendingTransactionTracker >; + const multichainTrackingHelperClassMock = + MultichainTrackingHelper as jest.MockedClass< + typeof MultichainTrackingHelper + >; + /** * Create a new instance of the TransactionController. * @@ -535,27 +585,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,52 +714,51 @@ 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; - - incomingTransactionHelperMock = { - hub: { - on: jest.fn(), - }, - } as unknown as jest.Mocked; - - pendingTransactionTrackerMock = { - start: jest.fn(), - hub: { - on: jest.fn(), - }, - forceCheckTransaction: jest.fn(), - } as unknown as jest.Mocked; + 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; + }); - incomingTransactionHelperClassMock.mockReturnValue( - incomingTransactionHelperMock, - ); + 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; + }); - pendingTransactionTrackerClassMock.mockReturnValue( - pendingTransactionTrackerMock, - ); + 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; + }); }); afterEach(() => { @@ -701,6 +825,8 @@ describe('TransactionController', () => { expect(getExternalPendingTransactions).toHaveBeenCalledTimes(1); expect(getExternalPendingTransactions).toHaveBeenCalledWith( ACCOUNT_MOCK, + // This is undefined for the base nonceTracker + undefined, ); }); }); @@ -711,10 +837,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, @@ -724,8 +849,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, }, ]; @@ -746,6 +884,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); }); }); }); @@ -862,8 +1002,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), @@ -974,7 +1114,7 @@ describe('TransactionController', () => { const { transactions } = controller.state; expect(transactions).toHaveLength(expectedTransactionCount); - expect(delayMessengerMock.call).toHaveBeenCalledTimes( + expect(messengerMock.call).toHaveBeenCalledTimes( expectedRequestApprovalCalledTimes, ); }, @@ -1080,6 +1220,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(); @@ -1091,6 +1295,7 @@ describe('TransactionController', () => { const expectedInitialSnapshot = { actionId: undefined, chainId: expect.any(String), + networkClientId: undefined, dappSuggestedGasFees: undefined, deviceConfirmedOn: undefined, id: expect.any(String), @@ -1111,6 +1316,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], @@ -1233,12 +1454,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, @@ -1270,8 +1489,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), @@ -1297,7 +1516,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 () => { @@ -1349,7 +1568,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), }); }); @@ -1519,7 +1740,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'); }); @@ -1540,7 +1761,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'); }); @@ -1561,7 +1782,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'); @@ -2453,20 +2674,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(); @@ -2577,7 +2784,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({ @@ -2612,7 +2819,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, @@ -2659,7 +2866,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'; @@ -2682,7 +2889,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, @@ -2797,7 +3004,7 @@ describe('TransactionController', () => { from: ACCOUNT_MOCK, to: ACCOUNT_2_MOCK, id: '1', - chainId: toHex(1), + chainId: toHex(5), status: TransactionStatus.confirmed, txParams: { gasUsed: undefined, @@ -2825,6 +3032,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', () => { @@ -3592,6 +3848,7 @@ describe('TransactionController', () => { gas: '0x222', to: ACCOUNT_2_MOCK, value: '0x1', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; await expect( @@ -3621,6 +3878,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 @@ -3651,6 +3909,7 @@ describe('TransactionController', () => { gas: '0x111', to: ACCOUNT_2_MOCK, value: '0x0', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; const mockTransactionParam2 = { from: ACCOUNT_MOCK, @@ -3658,6 +3917,7 @@ describe('TransactionController', () => { gas: '0x222', to: ACCOUNT_2_MOCK, value: '0x1', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; const result = await controller.approveTransactionsWithSameNonce([ @@ -3688,6 +3948,7 @@ describe('TransactionController', () => { gas: '0x111', to: ACCOUNT_2_MOCK, value: '0x0', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; const mockTransactionParam2 = { from: ACCOUNT_MOCK, @@ -3695,6 +3956,7 @@ describe('TransactionController', () => { gas: '0x222', to: ACCOUNT_2_MOCK, value: '0x1', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; await expect( @@ -3706,10 +3968,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 = { @@ -3718,6 +3976,7 @@ describe('TransactionController', () => { gas: '0x111', to: ACCOUNT_2_MOCK, value: '0x0', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; const mockTransactionParam2 = { @@ -3726,6 +3985,7 @@ describe('TransactionController', () => { gas: '0x222', to: ACCOUNT_2_MOCK, value: '0x1', + chainId: MOCK_NETWORK.state.providerConfig.chainId, }; await controller.approveTransactionsWithSameNonce( @@ -3733,7 +3993,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'); }); }); @@ -4216,8 +4505,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, @@ -4228,7 +4517,7 @@ describe('TransactionController', () => { }, false, ); - expect(delayMessengerMock.call).toHaveBeenCalledWith( + expect(messengerMock.call).toHaveBeenCalledWith( 'ApprovalController:addRequest', { expectsResult: true, @@ -4241,6 +4530,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, @@ -4271,12 +4613,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 }; @@ -4290,12 +4638,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(); @@ -4305,14 +4647,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 ec80c8a8ac..f7eeb0105a 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -15,7 +15,6 @@ import { BaseControllerV1 } from '@metamask/base-controller'; import { query, NetworkType, - RPC, ApprovalType, ORIGIN_METAMASK, convertHexToDecimal, @@ -24,9 +23,15 @@ 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 { Mutex } from 'async-mutex'; @@ -42,7 +47,9 @@ import type { import { v1 as random } from 'uuid'; import { EtherscanRemoteTransactionSource } from './helpers/EtherscanRemoteTransactionSource'; +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 { @@ -98,7 +105,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 +135,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 +151,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 +165,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 */ @@ -183,6 +189,89 @@ export const CANCEL_RATE = 1.1; */ 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 +280,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 +293,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 +317,6 @@ export class TransactionController extends BaseControllerV1< TransactionConfig, TransactionState > { - private readonly ethQuery: EthQuery; - private readonly isHistoryDisabled: boolean; private readonly isSwapsDisabled: boolean; @@ -239,8 +331,6 @@ 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 getSavedGasFees: (chainId: Hex) => SavedGasFees | undefined; @@ -249,7 +339,9 @@ export class TransactionController extends BaseControllerV1< private readonly getCurrentAccountEIP1559Compatibility: () => Promise; - private readonly getCurrentNetworkEIP1559Compatibility: () => Promise; + private readonly getCurrentNetworkEIP1559Compatibility: ( + networkClientId?: NetworkClientId, + ) => Promise; private readonly getGasFeeEstimates: () => Promise; @@ -259,14 +351,19 @@ 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; + readonly #pendingTransactionOptions: PendingTransactionOptions; + private readonly pendingTransactionTracker: PendingTransactionTracker; private readonly signAbortCallbacks: Map void> = new Map(); @@ -324,6 +421,8 @@ export class TransactionController extends BaseControllerV1< return { registryMethod, parsedRegistryMethod }; } + #multichainTrackingHelper: MultichainTrackingHelper; + /** * EventEmitter instance used to listen to specific transactional events */ @@ -343,43 +442,6 @@ 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.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.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, @@ -400,53 +462,10 @@ export class TransactionController extends BaseControllerV1< pendingTransactions = {}, provider, securityProviderRequest, - hooks = {}, - }: { - blockTracker: BlockTracker; - 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; - 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, ) { @@ -461,13 +480,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; @@ -485,6 +500,8 @@ export class TransactionController extends BaseControllerV1< this.getExternalPendingTransactions = getExternalPendingTransactions ?? (() => []); this.securityProviderRequest = securityProviderRequest; + this.#incomingTransactionOptions = incomingTransactions; + this.#pendingTransactionOptions = pendingTransactions; this.afterSign = hooks?.afterSign ?? (() => true); this.beforeApproveOnInit = hooks?.beforeApproveOnInit ?? (() => true); @@ -498,73 +515,84 @@ 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, - 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(); + // 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. * @@ -609,6 +637,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( @@ -623,6 +652,7 @@ export class TransactionController extends BaseControllerV1< sendFlowHistory, swaps = {}, type, + networkClientId, }: { actionId?: string; deviceConfirmedOn?: WalletDevice; @@ -636,13 +666,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); @@ -660,11 +701,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 || { @@ -682,6 +728,7 @@ export class TransactionController extends BaseControllerV1< userEditedGasLimit: false, verifiedOnBlockchain: false, type: transactionType, + networkClientId, }; await this.updateGasProperties(transactionMeta); @@ -727,16 +774,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(networkClientIds: NetworkClientId[] = []) { + if (networkClientIds.length === 0) { + this.incomingTransactionHelper.stop(); + return; + } + this.#multichainTrackingHelper.stopIncomingTransactionPolling( + networkClientIds, + ); } - stopIncomingTransactionPolling() { + 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, + ); } /** @@ -843,7 +913,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, @@ -864,11 +937,20 @@ export class TransactionController extends BaseControllerV1< txParams: newTxParams, }); - const hash = await this.publishTransactionForRetry(rawTx, transactionMeta); + 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(), @@ -998,7 +1080,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, @@ -1016,7 +1101,15 @@ export class TransactionController extends BaseControllerV1< log('Submitting speed up transaction', { oldFee, newFee, txParams }); - const hash = await this.publishTransactionForRetry(rawTx, transactionMeta); + const ethQuery = this.#multichainTrackingHelper.getEthQuery({ + networkClientId: transactionMeta.networkClientId, + chainId: transactionMeta.chainId, + }); + const hash = await this.publishTransactionForRetry( + ethQuery, + rawTx, + transactionMeta, + ); const baseTransactionMeta: TransactionMeta = { ...transactionMeta, @@ -1068,12 +1161,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 }; @@ -1084,14 +1184,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); @@ -1183,14 +1288,6 @@ export class TransactionController extends BaseControllerV1< }); } - startIncomingTransactionProcessing() { - this.incomingTransactionHelper.start(); - } - - stopIncomingTransactionProcessing() { - this.incomingTransactionHelper.stop(); - } - /** * Adds external provided transaction to state as confirmed transaction. * @@ -1437,15 +1534,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, + ); } /** @@ -1506,7 +1602,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; @@ -1526,7 +1625,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', { @@ -1538,12 +1637,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)) { @@ -1559,7 +1672,7 @@ 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 @@ -1573,7 +1686,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) { @@ -1789,6 +1902,7 @@ export class TransactionController extends BaseControllerV1< } private async signExternalTransaction( + chainId: Hex, transactionParams: TransactionParams, ): Promise { if (!this.sign) { @@ -1796,7 +1910,6 @@ export class TransactionController extends BaseControllerV1< } const normalizedTransactionParams = normalizeTxParams(transactionParams); - const chainId = this.getChainId(); const type = isEIP1559Transaction(normalizedTransactionParams) ? TransactionEnvelopeType.feeMarket : TransactionEnvelopeType.legacy; @@ -1808,7 +1921,7 @@ export class TransactionController extends BaseControllerV1< }; const { from } = updatedTransactionParams; - const common = this.getCommonConfiguration(); + const common = this.getCommonConfiguration(chainId); const unsignedTransaction = TransactionFactory.fromTxData( updatedTransactionParams, { common }, @@ -1862,45 +1975,52 @@ 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), + ethQuery: this.#multichainTrackingHelper.getEthQuery({ + networkClientId, + chainId, + }), + getSavedGasFees: this.getSavedGasFees.bind(this), getGasFeeEstimates: this.getGasFeeEstimates.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) => { @@ -2037,12 +2157,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; @@ -2055,7 +2174,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; @@ -2068,14 +2187,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, @@ -2111,10 +2231,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; @@ -2129,7 +2254,7 @@ export class TransactionController extends BaseControllerV1< ); if (hash === undefined) { - hash = await this.publishTransaction(rawTx); + hash = await this.publishTransaction(ethQuery, rawTx); } log('Publish successful', hash); @@ -2162,8 +2287,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]); } /** @@ -2315,15 +2443,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), }); } @@ -2334,23 +2471,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, }; @@ -2440,7 +2565,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( @@ -2481,10 +2606,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) => @@ -2501,8 +2629,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); @@ -2571,9 +2699,9 @@ export class TransactionController extends BaseControllerV1< } } - private async getEIP1559Compatibility() { + private async getEIP1559Compatibility(networkClientId?: NetworkClientId) { const currentNetworkIsEIP1559Compatible = - await this.getCurrentNetworkEIP1559Compatibility(); + await this.getCurrentNetworkEIP1559Compatibility(networkClientId); const currentAccountIsEIP1559Compatible = await this.getCurrentAccountEIP1559Compatibility(); @@ -2583,35 +2711,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); @@ -2672,26 +2781,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, @@ -2719,9 +2815,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), }); @@ -2736,12 +2836,191 @@ export class TransactionController extends BaseControllerV1< } } + #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(rawTx); + const hash = await this.publishTransaction(ethQuery, rawTx); return hash; } catch (error: unknown) { if (this.isTransactionAlreadyConfirmedError(error as Error)) { diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts new file mode 100644 index 0000000000..bffbb78e4a --- /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 '../test/EtherscanMocks'; +import { + buildEthGasPriceRequestMock, + buildEthBlockNumberRequestMock, + buildEthGetCodeRequestMock, + buildEthGetBlockByNumberRequestMock, + buildEthEstimateGasRequestMock, + buildEthGetTransactionCountRequestMock, + buildEthGetBlockByHashRequestMock, + buildEthSendRawTransactionRequestMock, + buildEthGetTransactionReceiptRequestMock, +} from '../test/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/helpers/EtherscanRemoteTransactionSource.test.ts b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts index 617d6765f9..d70c394bd8 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 '../../test/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..5274d6c9b4 100644 --- a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts +++ b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts @@ -1,5 +1,6 @@ import { BNToHex } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; +import { Mutex } from 'async-mutex'; import { BN } from 'ethereumjs-util'; import { v1 as random } from 'uuid'; @@ -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/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 71bfb86be4..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 diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index ae48ba4c48..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,24 +123,23 @@ 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. @@ -150,7 +147,7 @@ export class PendingTransactionTracker { * @param txMeta - The transaction to check */ async forceCheckTransaction(txMeta: TransactionMeta) { - const nonceGlobalLock = await this.#nonceTracker.getGlobalLock(); + const releaseLock = await this.#getGlobalLock(); try { await this.#checkTransaction(txMeta); @@ -158,7 +155,7 @@ export class PendingTransactionTracker { /* istanbul ignore next */ log('Failed to check transaction', error); } finally { - nonceGlobalLock.releaseLock(); + releaseLock(); } } @@ -173,7 +170,7 @@ export class PendingTransactionTracker { log('Started polling'); } - #stop() { + stop() { if (!this.#running) { return; } @@ -185,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(); @@ -193,7 +190,7 @@ export class PendingTransactionTracker { /* istanbul ignore next */ log('Failed to check transactions', error); } finally { - nonceGlobalLock.releaseLock(); + releaseLock(); } try { @@ -295,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/types.ts b/packages/transaction-controller/src/types.ts index c2610741d0..c805f776d9 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1,4 +1,5 @@ import type { AccessList } from '@ethereumjs/tx'; +import type { NetworkClientId } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import type { Operation } from 'fast-json-patch'; @@ -200,6 +201,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 * 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.ts b/packages/transaction-controller/src/utils/gas-fees.ts index b550470488..2eb04c1c95 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -7,8 +7,12 @@ import { toHex, } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import type { GasFeeState } from '@metamask/gas-fee-controller'; +import type { + FetchGasFeeEstimateOptions, + GasFeeState, +} from '@metamask/gas-fee-controller'; import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; +import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { addHexPrefix } from 'ethereumjs-util'; @@ -25,8 +29,10 @@ import { SWAP_TRANSACTION_TYPES } from './swaps'; export type UpdateGasFeesRequest = { eip1559: boolean; ethQuery: EthQuery; - getSavedGasFees: () => SavedGasFees | undefined; - getGasFeeEstimates: () => Promise; + getSavedGasFees: (chainId: Hex) => SavedGasFees | undefined; + getGasFeeEstimates: ( + options: FetchGasFeeEstimateOptions, + ) => Promise; txMeta: TransactionMeta; }; @@ -45,7 +51,9 @@ 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); @@ -268,7 +276,9 @@ async function getSuggestedGasFees(request: UpdateGasFeesRequest) { } try { - const { gasFeeEstimates, gasEstimateType } = await getGasFeeEstimates(); + const { gasFeeEstimates, gasEstimateType } = await getGasFeeEstimates({ + networkClientId: txMeta.networkClientId, + }); if (eip1559 && gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { const { 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..4fc306e218 100644 --- a/packages/transaction-controller/src/utils/gas.ts +++ b/packages/transaction-controller/src/utils/gas.ts @@ -2,13 +2,12 @@ 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 type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { addHexPrefix } from 'ethereumjs-util'; @@ -18,7 +17,8 @@ import type { TransactionMeta, TransactionParams } from '../types'; export type UpdateGasRequest = { ethQuery: EthQuery; - providerConfig: ProviderConfig; + isCustomNetwork: boolean; + chainId: Hex; txMeta: TransactionMeta; }; @@ -120,7 +120,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 +137,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 +159,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/test/EtherscanMocks.ts b/packages/transaction-controller/test/EtherscanMocks.ts new file mode 100644 index 0000000000..6598f9b9bc --- /dev/null +++ b/packages/transaction-controller/test/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/test/JsonRpcRequestMocks.ts b/packages/transaction-controller/test/JsonRpcRequestMocks.ts new file mode 100644 index 0000000000..101009fce5 --- /dev/null +++ b/packages/transaction-controller/test/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/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 b03168c257..614fb1e1be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2917,6 +2917,7 @@ __metadata: 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 From 5ce07fe3ad3ca2104b5061493a7ae51dfece680d Mon Sep 17 00:00:00 2001 From: jiexi Date: Thu, 15 Feb 2024 15:25:59 -0800 Subject: [PATCH 09/39] Release 117.0.0 (#3925) ## Explanation Releases new major version of `@metamask/transaction-controller` and downstream `@metamask/user-operation-controller`. --------- Co-authored-by: Elliot Winkler --- package.json | 2 +- packages/transaction-controller/CHANGELOG.md | 40 ++++++++++++++++++- packages/transaction-controller/package.json | 2 +- .../user-operation-controller/CHANGELOG.md | 9 ++++- .../user-operation-controller/package.json | 6 +-- yarn.lock | 6 +-- 6 files changed, 55 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index f337c35e8e..d955485a21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "116.0.0", + "version": "117.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 94cee037a4..36c068a253 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -495,7 +532,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@22.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@23.0.0...HEAD +[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 diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 553e20ed2b..3ce40e2a07 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "22.0.0", + "version": "23.0.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", diff --git a/packages/user-operation-controller/CHANGELOG.md b/packages/user-operation-controller/CHANGELOG.md index 2088375cee..da5d35b23d 100644 --- a/packages/user-operation-controller/CHANGELOG.md +++ b/packages/user-operation-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -43,7 +49,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 2e7885f36b..4146ac2c7b 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", @@ -41,7 +41,7 @@ "@metamask/network-controller": "^17.2.0", "@metamask/polling-controller": "^5.0.0", "@metamask/rpc-errors": "^6.1.0", - "@metamask/transaction-controller": "^22.0.0", + "@metamask/transaction-controller": "^23.0.0", "@metamask/utils": "^8.3.0", "ethereumjs-util": "^7.0.10", "immer": "^9.0.6", @@ -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": "^22.0.0" + "@metamask/transaction-controller": "^23.0.0" }, "engines": { "node": ">=16.0.0" diff --git a/yarn.lock b/yarn.lock index 614fb1e1be..fcc17862f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2889,7 +2889,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@^22.0.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@^23.0.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -2947,7 +2947,7 @@ __metadata: "@metamask/network-controller": ^17.2.0 "@metamask/polling-controller": ^5.0.0 "@metamask/rpc-errors": ^6.1.0 - "@metamask/transaction-controller": ^22.0.0 + "@metamask/transaction-controller": ^23.0.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 @@ -2966,7 +2966,7 @@ __metadata: "@metamask/gas-fee-controller": ^13.0.0 "@metamask/keyring-controller": ^12.2.0 "@metamask/network-controller": ^17.2.0 - "@metamask/transaction-controller": ^22.0.0 + "@metamask/transaction-controller": ^23.0.0 languageName: unknown linkType: soft From 414d6fbf57615ac918f4dd697ef029b4f56d4dd7 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Fri, 16 Feb 2024 10:25:15 -0330 Subject: [PATCH 10/39] refactor(queued-request-controller): Migrate request count to state (#3919) ## Explanation The current number of requests queued by the `QueuedRequestController` is currently stored as a private instance variable and exposed by the `length` method and the `countChanged` event. This works, but the conventional way of tracking and sharing state is to use controller state. This is what controller state is for, and more broadly this is why controllers exist: to manage state. To better align with our conventions, the requests queue length has been migrated into controller state. It is now represented by the state property `queuedRequestCount`. The old `length` method and `countChanged` event remains, but they have been marked as deprecated. They can be removed in a future release. Additionally, types have been added for the built-in `stateChanged` event and `getState` action. ## References This was done to help unblock RPC request queue batching (#3763). This isn't strictly required for that work, but it made it simpler to implement. ## Changelog ### `@metamask/queued-request-controller #### Added - Add `queuedRequestCount` state - Add pre-existing `getState` action and `stateChange` event to type exports #### Changed - Deprecate the `length` method in favor of the `queuedRequestCount` state - Deprecate the `countChanged` event in favor of the `stateChange` event ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../src/QueuedRequestController.test.ts | 50 +++++++++++++- .../src/QueuedRequestController.ts | 66 ++++++++++++++----- 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/packages/queued-request-controller/src/QueuedRequestController.test.ts b/packages/queued-request-controller/src/QueuedRequestController.test.ts index b0923808aa..a14bcd3032 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.test.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.test.ts @@ -1,4 +1,5 @@ import { ControllerMessenger } from '@metamask/base-controller'; +import { createDeferredPromise } from '@metamask/utils'; import type { QueuedRequestControllerActions, @@ -35,10 +36,43 @@ 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(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( + () => firstRequestProcessing, + ); + const secondRequest = controller.enqueueRequest(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(), @@ -108,6 +142,20 @@ describe('QueuedRequestController', () => { expect(controller.length()).toBe(0); }); + it('correctly updates the request queue count upon failure', async () => { + const options: QueuedRequestControllerOptions = { + messenger: buildQueuedRequestControllerMessenger(), + }; + const controller = new QueuedRequestController(options); + + await expect(() => + controller.enqueueRequest(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 () => { const options: QueuedRequestControllerOptions = { messenger: buildQueuedRequestControllerMessenger(), diff --git a/packages/queued-request-controller/src/QueuedRequestController.ts b/packages/queued-request-controller/src/QueuedRequestController.ts index aa32476156..5f6ff7d9ea 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.ts @@ -1,33 +1,59 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; export const controllerName = 'QueuedRequestController'; +export type QueuedRequestControllerState = { + queuedRequestCount: number; +}; + export const QueuedRequestControllerActionTypes = { enqueueRequest: `${controllerName}:enqueueRequest` as const, + getState: `${controllerName}:getState` as const, +}; + +export type QueuedRequestControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + QueuedRequestControllerState +>; + +export type QueuedRequestControllerEnqueueRequestAction = { + type: typeof QueuedRequestControllerActionTypes.enqueueRequest; + handler: QueuedRequestController['enqueueRequest']; }; export const QueuedRequestControllerEventTypes = { countChanged: `${controllerName}:countChanged` as const, + stateChange: `${controllerName}:stateChange` as const, }; -export type QueuedRequestControllerState = Record; +export type QueuedRequestControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + QueuedRequestControllerState + >; +/** + * This event is fired when the number of queued requests changes. + * + * @deprecated Use the `QueuedRequestController:stateChange` event instead + */ export type QueuedRequestControllerCountChangedEvent = { type: typeof QueuedRequestControllerEventTypes.countChanged; payload: [number]; }; -export type QueuedRequestControllerEnqueueRequestAction = { - type: typeof QueuedRequestControllerActionTypes.enqueueRequest; - handler: QueuedRequestController['enqueueRequest']; -}; - export type QueuedRequestControllerEvents = - QueuedRequestControllerCountChangedEvent; + | QueuedRequestControllerCountChangedEvent + | QueuedRequestControllerStateChangeEvent; export type QueuedRequestControllerActions = - QueuedRequestControllerEnqueueRequestAction; + | QueuedRequestControllerGetStateAction + | QueuedRequestControllerEnqueueRequestAction; export type QueuedRequestControllerMessenger = RestrictedControllerMessenger< typeof controllerName, @@ -60,8 +86,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 +94,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(); } @@ -91,16 +120,19 @@ export class QueuedRequestController extends BaseController< * @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. + * @deprecated This method is deprecated; use `state.queuedRequestCount` directly instead. */ length() { - return this.#count; + return this.state.queuedRequestCount; } #updateCount(change: -1 | 1) { - this.#count += change; + this.update((state) => { + state.queuedRequestCount += change; + }); this.messagingSystem.publish( 'QueuedRequestController:countChanged', - this.#count, + this.state.queuedRequestCount, ); } @@ -119,7 +151,7 @@ export class QueuedRequestController extends BaseController< async enqueueRequest(requestNext: (...arg: unknown[]) => Promise) { this.#updateCount(1); - if (this.#count > 1) { + if (this.state.queuedRequestCount > 1) { await this.currentRequest; } From 66c15b1b188b457d4f1bfe6501a3af922a7a4402 Mon Sep 17 00:00:00 2001 From: jiexi Date: Tue, 20 Feb 2024 09:55:16 -0800 Subject: [PATCH 11/39] Fix TokenDetectionController.detectTokens() used tokens states when passed networkClientId (#3914) ## Explanation Currently, `detectTokens()` always uses the state for the globally selected network from the `TokenListController` and `TokensController` regardless of if/what networkClientId is passed in. This PR updates `detectTokens()` to use the chainId keyed states of the `TokenListController` and `TokensController` instead. ## References Fixes: https://github.com/MetaMask/core/issues/3905 ## Changelog ### `@metamask/assets-controllers` - **FIXED**: `TokenDetectionController.detectTokens()` now reads the chainId keyed state properties from `TokenListController` and `TokensController` rather than incorrectly using the globally selected state properties when networkClientId is passed ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: Alex Donesky Co-authored-by: Jongsun Suh --- packages/assets-controllers/jest.config.js | 2 +- .../src/TokenDetectionController.test.ts | 372 +++++++++++------- .../src/TokenDetectionController.ts | 13 +- 3 files changed, 237 insertions(+), 150 deletions(-) diff --git a/packages/assets-controllers/jest.config.js b/packages/assets-controllers/jest.config.js index fc28607153..761ed600dd 100644 --- a/packages/assets-controllers/jest.config.js +++ b/packages/assets-controllers/jest.config.js @@ -17,7 +17,7 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 88.93, + branches: 88.8, functions: 96.71, lines: 97.34, statements: 97.4, diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index f16e696c43..971bd3d49b 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -293,15 +293,20 @@ describe('TokenDetectionController', () => { async ({ controller, mockTokenListGetState, 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, + 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, + }, + }, }, }, }); @@ -336,15 +341,20 @@ describe('TokenDetectionController', () => { async ({ controller, mockTokenListGetState, 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, + 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, + }, + }, }, }, }); @@ -382,22 +392,27 @@ describe('TokenDetectionController', () => { async ({ controller, mockTokenListGetState, callActionSpy }) => { 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, + 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] = { + tokenListState.tokensChainsCache['0x1'].data[sampleTokenB.address] = { name: sampleTokenB.name, symbol: sampleTokenB.symbol, decimals: sampleTokenB.decimals, @@ -446,15 +461,20 @@ describe('TokenDetectionController', () => { }); 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, + 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, + }, + }, }, }, }); @@ -483,15 +503,20 @@ describe('TokenDetectionController', () => { async ({ controller, mockTokenListGetState, 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, + 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, + }, + }, }, }, }); @@ -541,15 +566,20 @@ describe('TokenDetectionController', () => { }) => { 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, + 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, + }, + }, }, }, }); @@ -592,15 +622,20 @@ describe('TokenDetectionController', () => { }) => { 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, + 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, + }, + }, }, }, }); @@ -643,15 +678,20 @@ describe('TokenDetectionController', () => { }) => { 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, + 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, + }, + }, }, }, }); @@ -695,15 +735,20 @@ describe('TokenDetectionController', () => { }) => { 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, + 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, + }, + }, }, }, }); @@ -757,15 +802,20 @@ describe('TokenDetectionController', () => { }) => { 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, + 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, + }, + }, }, }, }); @@ -810,15 +860,20 @@ describe('TokenDetectionController', () => { }) => { 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, + 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, + }, + }, }, }, }); @@ -1200,15 +1255,20 @@ describe('TokenDetectionController', () => { }) => { 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, + 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, + }, + }, }, }, }); @@ -1252,15 +1312,20 @@ describe('TokenDetectionController', () => { }) => { 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, + 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, + }, + }, }, }, }); @@ -1456,17 +1521,24 @@ describe('TokenDetectionController', () => { 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, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, + tokenList, + tokensChainsCache: { + '0x1': { + timestamp: 0, + data: tokenList, }, }, }; @@ -1804,15 +1876,20 @@ describe('TokenDetectionController', () => { async ({ controller, mockTokenListGetState, 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, + 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, + }, + }, }, }, }); @@ -1854,15 +1931,20 @@ describe('TokenDetectionController', () => { async ({ controller, mockTokenListGetState }) => { 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, + 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, + }, + }, }, }, }); diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index e570cce454..adb7503a3c 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -451,16 +451,21 @@ export class TokenDetectionController extends StaticIntervalPollingController< } const isTokenDetectionInactiveInMainnet = !this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet; - const { tokenList } = this.messagingSystem.call( + const { tokensChainsCache } = this.messagingSystem.call( 'TokenListController:getState', ); + const tokenList = tokensChainsCache[chainId]?.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 tokens = allTokens[chainId]?.[selectedAddress] ?? []; + const detectedTokens = allDetectedTokens[chainId]?.[selectedAddress] ?? []; + const ignoredTokens = allIgnoredTokens[chainId]?.[selectedAddress] ?? []; + const tokensToDetect: string[] = []; for (const tokenAddress of Object.keys(tokenListUsed)) { if ( From dfc592583ef6c51c873816b48be9c79921a2ffe4 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 21 Feb 2024 10:15:31 +0000 Subject: [PATCH 12/39] Support updated Linea gas fee estimation (#3913) Support the new Linea gas fee flow using the `linea_estimateGas` RPC method to recommend a baseline `baseFeePerGas` and `priorityFeePerGas` specific to each transaction on Linea chains. Implement a `GasFeeFlow` architecture to support alternate modularised sources of gas fee estimates depending on transaction criteria such as chain ID. Implement a `GasFeePoller` helper to automatically monitor all `unapproved` transactions and periodically store updated gas fee estimates in their metadata using suitable gas fee flows. --- .../transaction-controller/jest.config.js | 8 +- .../src/TransactionController.test.ts | 64 ++++- .../src/TransactionController.ts | 67 ++++- .../src/gas-flows/DefaultGasFeeFlow.test.ts | 152 ++++++++++++ .../src/gas-flows/DefaultGasFeeFlow.ts | 115 +++++++++ .../src/gas-flows/LineaGasFeeFlow.test.ts | 196 +++++++++++++++ .../src/gas-flows/LineaGasFeeFlow.ts | 228 ++++++++++++++++++ .../src/helpers/GasFeePoller.test.ts | 220 +++++++++++++++++ .../src/helpers/GasFeePoller.ts | 163 +++++++++++++ packages/transaction-controller/src/index.ts | 1 + packages/transaction-controller/src/types.ts | 73 ++++++ .../src/utils/gas-fees.test.ts | 186 +++----------- .../src/utils/gas-fees.ts | 79 +++--- .../src/utils/gas-flow.test.ts | 157 ++++++++++++ .../src/utils/gas-flow.ts | 119 +++++++++ 15 files changed, 1613 insertions(+), 215 deletions(-) create mode 100644 packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.test.ts create mode 100644 packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts create mode 100644 packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts create mode 100644 packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts create mode 100644 packages/transaction-controller/src/helpers/GasFeePoller.test.ts create mode 100644 packages/transaction-controller/src/helpers/GasFeePoller.ts create mode 100644 packages/transaction-controller/src/utils/gas-flow.test.ts create mode 100644 packages/transaction-controller/src/utils/gas-flow.ts diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js index eeeef9619a..d09576381e 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.73, - statements: 97.76, + branches: 91.8, + functions: 98.58, + lines: 98.91, + statements: 98.92, }, }, diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 2df7c26044..eb2f5af802 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -29,6 +29,9 @@ 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'; @@ -56,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'), @@ -63,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 } = { @@ -199,10 +208,6 @@ jest.mock('@metamask/eth-query', () => }), ); -jest.mock('./helpers/IncomingTransactionHelper'); -jest.mock('./helpers/PendingTransactionTracker'); -jest.mock('./helpers/MultichainTrackingHelper'); - /** * Builds a mock block tracker with a canned block number that can be used in * tests. @@ -528,6 +533,9 @@ 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; @@ -538,6 +546,9 @@ describe('TransactionController', () => { 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 = @@ -759,6 +770,29 @@ describe('TransactionController', () => { } as unknown as jest.Mocked; return multichainTrackingHelperMock; }); + + defaultGasFeeFlowClassMock.mockImplementation(() => { + defaultGasFeeFlowMock = { + matchesTransaction: () => false, + } as unknown as jest.Mocked; + return defaultGasFeeFlowMock; + }); + + 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(() => { @@ -783,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 @@ -1587,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), }); }); diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index f7eeb0105a..0823a4ce63 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -46,7 +46,10 @@ 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'; @@ -63,6 +66,7 @@ import type { TransactionReceipt, WalletDevice, SecurityAlertResponse, + GasFeeFlow, } from './types'; import { TransactionEnvelopeType, @@ -333,6 +337,8 @@ export class TransactionController extends BaseControllerV1< private readonly mutex = new Mutex(); + private readonly gasFeeFlows: GasFeeFlow[]; + private readonly getSavedGasFees: (chainId: Hex) => SavedGasFees | undefined; private readonly getNetworkState: () => NetworkState; @@ -571,6 +577,27 @@ export class TransactionController extends BaseControllerV1< blockTracker, }); + 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, + onStateChange: (listener) => { + this.subscribe(listener); + }, + }); + + 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); @@ -1214,15 +1241,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, + }); } /** @@ -2003,8 +2025,9 @@ export class TransactionController extends BaseControllerV1< networkClientId, chainId, }), + gasFeeFlows: this.gasFeeFlows, + getGasFeeEstimates: this.getGasFeeEstimates, getSavedGasFees: this.getSavedGasFees.bind(this), - getGasFeeEstimates: this.getGasFeeEstimates.bind(this), txMeta: transactionMeta, }); } @@ -3048,4 +3071,28 @@ export class TransactionController extends BaseControllerV1< 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/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..735faec26f --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts @@ -0,0 +1,196 @@ +import { query } 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 { 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: '0x1', + priorityFeePerGas: '0x2', +}; + +const GAS_FEE_CONTROLLER_RESPONSE_MOCK: GasFeeState = { + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + gasFeeEstimates: { + low: { + suggestedMaxFeePerGas: '1', + suggestedMaxPriorityFeePerGas: '2', + }, + medium: { + suggestedMaxFeePerGas: '3', + suggestedMaxPriorityFeePerGas: '4', + }, + high: { + suggestedMaxFeePerGas: '5', + suggestedMaxPriorityFeePerGas: '6', + }, + }, +} as GasFeeState; + +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(); + + getGasFeeControllerEstimatesMock = jest.fn(); + getGasFeeControllerEstimatesMock.mockResolvedValue( + GAS_FEE_CONTROLLER_RESPONSE_MOCK, + ); + + 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 gas fee controller estimate differences', async () => { + const flow = new LineaGasFeeFlow(); + const response = await flow.getGasFees(request); + + expect( + Object.values(response.estimates).map( + (level) => level.maxPriorityFeePerGas, + ), + ).toStrictEqual(['0x2', '0x77359402', '0xee6b2802']); + }); + + it('returns max fees using custom RPC method and base fee multipliers', async () => { + const flow = new LineaGasFeeFlow(); + const response = await flow.getGasFees(request); + + expect( + Object.values(response.estimates).map((level) => level.maxFeePerGas), + ).toStrictEqual(['0x3', '0x77359403', '0xee6b2803']); + }); + + it('uses default flow if gas fee estimate type is not fee market', async () => { + jest + .spyOn(DefaultGasFeeFlow.prototype, 'getGasFees') + .mockResolvedValue(RESPONSE_MOCK); + + const defaultGasFeeFlowGetGasFeesMock = jest.mocked( + DefaultGasFeeFlow.prototype.getGasFees, + ); + + getGasFeeControllerEstimatesMock.mockResolvedValue({ + ...GAS_FEE_CONTROLLER_RESPONSE_MOCK, + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, + } as GasFeeState); + + const flow = new LineaGasFeeFlow(); + const response = await flow.getGasFees(request); + + expect(response).toStrictEqual(RESPONSE_MOCK); + + expect(defaultGasFeeFlowGetGasFeesMock).toHaveBeenCalledTimes(1); + expect(defaultGasFeeFlowGetGasFeesMock).toHaveBeenCalledWith(request); + }); + + 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..290ae7bed4 --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts @@ -0,0 +1,228 @@ +import { + ChainId, + gweiDecToWEIBN, + hexToBN, + query, + toHex, +} from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; +import type { GasFeeEstimates as GasFeeControllerEstimates } from '@metamask/gas-fee-controller'; +import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; +import { createModuleLogger, type Hex } from '@metamask/utils'; +import type { BN } from 'ethereumjs-util'; + +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 ONE_GWEI_IN_WEI = 1e9; + +const LINEA_CHAIN_IDS: Hex[] = [ + ChainId['linea-mainnet'], + ChainId['linea-goerli'], +]; + +const BASE_FEE_MULTIPLIERS = { + low: 1, + medium: 1.35, + high: 1.7, +}; + +/** + * 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. + * - The GasFeeController to provide the priority fee deltas based on recent block analysis. + */ +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, getGasFeeControllerEstimates, transactionMeta } = request; + const { networkClientId } = transactionMeta; + + const lineaResponse = await this.#getLineaResponse( + transactionMeta, + ethQuery, + ); + + log('Received Linea response', lineaResponse); + + const gasFeeControllerEstimates = await getGasFeeControllerEstimates({ + networkClientId, + }); + + log('Received gas fee controller estimates', gasFeeControllerEstimates); + + if ( + gasFeeControllerEstimates.gasEstimateType !== + GAS_ESTIMATE_TYPES.FEE_MARKET + ) { + throw new Error('No gas fee estimates available'); + } + + const baseFees = this.#getBaseFees(lineaResponse); + + const priorityFees = this.#getPriorityFees( + lineaResponse, + gasFeeControllerEstimates.gasFeeEstimates, + ); + + const maxFees = this.#getMaxFees(baseFees, priorityFees); + + this.#logDifferencesToGasFeeController( + maxFees, + gasFeeControllerEstimates.gasFeeEstimates, + ); + + 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, + gasPrice: '0x100000000', + }, + ]); + } + + #getBaseFees(lineaResponse: LineaEstimateGasResponse): FeesByLevel { + const baseFeeLow = hexToBN(lineaResponse.baseFeePerGas); + const baseFeeMedium = baseFeeLow.muln(BASE_FEE_MULTIPLIERS.medium); + const baseFeeHigh = baseFeeLow.muln(BASE_FEE_MULTIPLIERS.high); + + return { + low: baseFeeLow, + medium: baseFeeMedium, + high: baseFeeHigh, + }; + } + + #getPriorityFees( + lineaResponse: LineaEstimateGasResponse, + gasFeeEstimates: GasFeeControllerEstimates, + ): FeesByLevel { + const mediumPriorityIncrease = this.#getPriorityLevelDifference( + gasFeeEstimates, + GasFeeEstimateLevel.medium, + GasFeeEstimateLevel.low, + ); + + const highPriorityIncrease = this.#getPriorityLevelDifference( + gasFeeEstimates, + GasFeeEstimateLevel.high, + GasFeeEstimateLevel.medium, + ); + + const priorityFeeLow = hexToBN(lineaResponse.priorityFeePerGas); + const priorityFeeMedium = priorityFeeLow.add(mediumPriorityIncrease); + const priorityFeeHigh = priorityFeeMedium.add(highPriorityIncrease); + + return { + low: priorityFeeLow, + medium: priorityFeeMedium, + high: priorityFeeHigh, + }; + } + + #getPriorityLevelDifference( + gasFeeEstimates: GasFeeControllerEstimates, + firstLevel: GasFeeEstimateLevel, + secondLevel: GasFeeEstimateLevel, + ): BN { + return gweiDecToWEIBN( + gasFeeEstimates[firstLevel].suggestedMaxPriorityFeePerGas, + ).sub( + gweiDecToWEIBN( + gasFeeEstimates[secondLevel].suggestedMaxPriorityFeePerGas, + ), + ); + } + + #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), + }; + } + + #logDifferencesToGasFeeController( + maxFees: FeesByLevel, + gasFeeControllerEstimates: GasFeeControllerEstimates, + ) { + const calculateDifference = (level: GasFeeEstimateLevel) => { + const newMaxFeeWeiDec = maxFees[level].toNumber(); + const newMaxFeeGweiDec = newMaxFeeWeiDec / ONE_GWEI_IN_WEI; + + const oldMaxFeeGweiDec = parseFloat( + gasFeeControllerEstimates[level].suggestedMaxFeePerGas, + ); + + const percentDifference = (newMaxFeeGweiDec / oldMaxFeeGweiDec - 1) * 100; + + /* istanbul ignore next */ + return `${percentDifference > 0 ? '+' : ''}${percentDifference.toFixed( + 2, + )}%`; + }; + + log( + 'Difference to gas fee controller', + calculateDifference(GasFeeEstimateLevel.low), + calculateDifference(GasFeeEstimateLevel.medium), + calculateDifference(GasFeeEstimateLevel.high), + ); + } +} 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..590bc794a2 --- /dev/null +++ b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts @@ -0,0 +1,220 @@ +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, + }); + }); + + 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', async () => { + const listener = jest.fn(); + + gasFeeFlowMock.matchesTransaction.mockReturnValue(false); + + const gasFeePoller = new GasFeePoller(constructorOptions); + gasFeePoller.hub.on('transaction-updated', listener); + + triggerOnStateChange(); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + it('gas fee flow throws', async () => { + const listener = jest.fn(); + + gasFeeFlowMock.getGasFees.mockRejectedValue(new Error('TestError')); + + 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..83f1d8ba43 --- /dev/null +++ b/packages/transaction-controller/src/helpers/GasFeePoller.ts @@ -0,0 +1,163 @@ +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 { 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('Skipping update as no gas fee flow found', transactionMeta.id); + + return; + } + + log('Found gas fee flow', gasFeeFlow.constructor.name, transactionMeta.id); + + const request: GasFeeFlowRequest = { + ethQuery, + getGasFeeControllerEstimates: this.#getGasFeeControllerEstimates, + transactionMeta, + }; + + try { + const response = await gasFeeFlow.getGasFees(request); + + transactionMeta.gasFeeEstimates = response.estimates; + } catch (error) { + log('Failed to get suggested gas fees', transactionMeta.id, error); + return; + } + + 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/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 c805f776d9..4f92019ed0 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1,4 +1,9 @@ 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'; @@ -330,6 +335,9 @@ type TransactionMetaBase = { */ submittedTime?: number; + /** Alternate EIP-1559 gas fee estimates for multiple priority levels. */ + gasFeeEstimates?: GasFeeEstimates; + /** * The symbol of the token being swapped. */ @@ -966,3 +974,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/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 2eb04c1c95..cb9c9e067c 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -11,7 +11,6 @@ import type { FetchGasFeeEstimateOptions, GasFeeState, } from '@metamask/gas-fee-controller'; -import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { addHexPrefix } from 'ethereumjs-util'; @@ -22,24 +21,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: (chainId: Hex) => SavedGasFees | undefined; + 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'); @@ -59,10 +67,10 @@ export async function updateGasFees(request: UpdateGasFeesRequest) { log('Suggested gas fees', suggestedGasFees); - const getGasFeeRequest = { + const getGasFeeRequest: GetGasFeeRequest = { ...request, - savedGasFees, initialParams, + savedGasFees, suggestedGasFees, }; @@ -92,6 +100,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; @@ -202,6 +214,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; @@ -263,8 +280,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) || @@ -275,41 +295,16 @@ async function getSuggestedGasFees(request: UpdateGasFeesRequest) { return {}; } + const gasFeeFlow = getGasFeeFlow(txMeta, gasFeeFlows) as GasFeeFlow; + try { - const { gasFeeEstimates, gasEstimateType } = await getGasFeeEstimates({ - networkClientId: txMeta.networkClientId, + const response = await gasFeeFlow.getGasFees({ + ethQuery, + getGasFeeControllerEstimates: getGasFeeEstimates, + transactionMeta: txMeta, }); - 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), - }; - } + return response.estimates.medium; } catch (error) { log('Failed to get suggested gas fees', error); } @@ -322,7 +317,3 @@ async function getSuggestedGasFees(request: UpdateGasFeesRequest) { 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); +} From d7476512bd922d133c203c71b2fee6a13b671052 Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Wed, 21 Feb 2024 15:36:27 -0500 Subject: [PATCH 13/39] [composable-controller] Better typing for state, messenger, strict type checks for inputs, remove `#controllers` field (#3904) ## Explanation There are several issues with the current ComposableController implementation that should be addressed before further updates are made to the controller (e.g. #3627). - Removes `#controllers` class field, which is not being updated by `#updateChildController` or anywhere else. - Removing this makes it clear that the list of child controllers to be composed is determined at class instantiation and cannot be altered later. - This behavior is consistent with `#updateChildController` being a private method. - Types BaseController, ComposableController state with `Record` - Opted to use `any` to disable BaseController state type constraint, as there is no straightforward way to type `BaseControllerV1` state to be compatible with `Json`. - Adds a `isBaseController` type guard, ~and removes the deprecated `subscribed` property from `BaseController`~. - Removes `ControllerList` type in anticipation of https://github.com/MetaMask/core/issues/3627. Internally, `ControllerInstance` will be used to type child controllers or unions and tuples thereof. - Adds and exports `ControllerInstance`, `BaseControllerV{1,2}Instance` types. - Populates state metadata object in constructor with child controllers. ## References - Blocks #3627 - Closes #3716 - Replaces some TODOs with comment explaining necessity of `any` usage. - Closes #3907 ## Changelog Recorded under "Unreleased" heading in CHANGELOG files. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: Elliot Winkler --- packages/composable-controller/CHANGELOG.md | 15 ++ packages/composable-controller/package.json | 4 +- .../src/ComposableController.test.ts | 46 +++++- .../src/ComposableController.ts | 142 +++++++++++++----- packages/composable-controller/src/index.ts | 10 +- packages/composable-controller/tsconfig.json | 3 + yarn.lock | 2 + 7 files changed, 176 insertions(+), 46 deletions(-) diff --git a/packages/composable-controller/CHANGELOG.md b/packages/composable-controller/CHANGELOG.md index 26688cf855..32709313ae 100644 --- a/packages/composable-controller/CHANGELOG.md +++ b/packages/composable-controller/CHANGELOG.md @@ -7,6 +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:** 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] ### Changed diff --git a/packages/composable-controller/package.json b/packages/composable-controller/package.json index 432b6b3c03..53ad6a93be 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/yarn.lock b/yarn.lock index fcc17862f2..17c75c30e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1708,6 +1708,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 From 65f1bbacfb499c048100f3cf28f2b8e7a804d754 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Wed, 21 Feb 2024 18:14:32 -0600 Subject: [PATCH 14/39] Fix issue with `selectedNetworkMiddlware` where all domains are added to state regardless of whether they have permissions (#3908) ## @metamask/selected-network-controller ### Changed - **BREAKING:** Remove logic in `selectedNetworkMiddleware` to set a default `networkClientId` for the requesting origin when not already set. Now if no `networkClientId` is already set for the requesting origin, the middleware will not add the origin to `domains` state but will add the `networkClientId` currently set for the `selectedNetworkClient` from the `NetworkController` to the request object. - **BREAKING:** `setNetworkClientIdForDomain` now throws an error if passed `metamask` as its first (`domain`) argument - **BREAKING:** `setNetworkClientIdForDomain` now includes a check that the requesting `domain` has already been granted permissions in the `PermissionsController` before adding it to `domains` state and throws an error if the domain does not have permissions. - **BREAKING:** the `domains` state now no longer contains a `metamask` key. - **BREAKING:** `getProviderAndBlockTracker` now throws an error if called with any domain while the `perDomainNetwork` flag is false. (These changes help fix an issue where the `SelectedNetworkController` adds any and all domains the user visits to its domains state whether or not the user has connected to these sites.) Currently can be e2e tested on this WIP integration branch: https://github.com/MetaMask/metamask-extension/pull/22860 --------- Co-authored-by: Jiexi Luan --- .../src/SelectedNetworkController.ts | 80 +-- .../src/SelectedNetworkMiddleware.ts | 41 +- .../selected-network-controller/src/index.ts | 1 + .../tests/SelectedNetworkController.test.ts | 516 +++++++++++------- .../tests/SelectedNetworkMiddleware.test.ts | 78 +-- 5 files changed, 400 insertions(+), 316 deletions(-) diff --git a/packages/selected-network-controller/src/SelectedNetworkController.ts b/packages/selected-network-controller/src/SelectedNetworkController.ts index 38ec02ba78..f51497bba1 100644 --- a/packages/selected-network-controller/src/SelectedNetworkController.ts +++ b/packages/selected-network-controller/src/SelectedNetworkController.ts @@ -4,6 +4,7 @@ import type { BlockTrackerProxy, NetworkClientId, NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, NetworkControllerStateChangeEvent, ProviderProxy, } from '@metamask/network-controller'; @@ -24,7 +25,7 @@ const getDefaultState = () => ({ type Domain = string; -const METAMASK_DOMAIN = 'metamask' as const; +export const METAMASK_DOMAIN = 'metamask' as const; export const SelectedNetworkControllerActionTypes = { getState: `${controllerName}:getState` as const, @@ -60,12 +61,17 @@ 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']; +}; + +type PermissionControllerHasPermissions = { + type: `PermissionController:hasPermissions`; + handler: (domain: string) => boolean; }; export type SelectedNetworkControllerActions = @@ -73,7 +79,10 @@ export type SelectedNetworkControllerActions = | SelectedNetworkControllerGetNetworkClientIdForDomainAction | SelectedNetworkControllerSetNetworkClientIdForDomainAction; -export type AllowedActions = NetworkControllerGetNetworkClientByIdAction; +export type AllowedActions = + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetStateAction + | PermissionControllerHasPermissions; export type SelectedNetworkControllerEvents = SelectedNetworkControllerStateChangeEvent; @@ -133,17 +142,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 +171,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.state.perDomainNetwork) { + return metamaskSelectedNetworkClientId; } - return this.state.domains[METAMASK_DOMAIN]; + return this.state.domains[domain] ?? metamaskSelectedNetworkClientId; } /** @@ -206,11 +216,22 @@ export class SelectedNetworkController extends BaseController< * @returns The proxy and block tracker proxies. */ getProviderAndBlockTracker(domain: Domain): NetworkProxy { + if (!this.state.perDomainNetwork) { + throw new Error( + 'Provider and BlockTracker should be fetched from NetworkController when perDomainNetwork 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), @@ -229,12 +250,5 @@ export class SelectedNetworkController extends BaseController< 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..61d41ae4f0 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts @@ -7,25 +7,46 @@ import type { 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. * @returns The network controller restricted messenger. */ -export function buildSelectedNetworkControllerMessenger( +export function buildSelectedNetworkControllerMessenger({ messenger = new ControllerMessenger< SelectedNetworkControllerActions | AllowedActions, SelectedNetworkControllerEvents | AllowedEvents >(), -): SelectedNetworkControllerMessenger { + hasPermissions, +}: { + messenger?: ControllerMessenger< + SelectedNetworkControllerActions | AllowedActions, + SelectedNetworkControllerEvents | AllowedEvents + >; + hasPermissions?: boolean; +} = {}): SelectedNetworkControllerMessenger { messenger.registerActionHandler( 'NetworkController:getNetworkClientById', jest.fn().mockReturnValue({ @@ -33,239 +54,344 @@ export function buildSelectedNetworkControllerMessenger( blockTracker: { getLatestBlock: jest.fn() }, }), ); + messenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ selectedNetworkClientId: 'mainnet' }), + ); + messenger.registerActionHandler( + 'PermissionController:hasPermissions', + jest.fn().mockReturnValue(hasPermissions), + ); return messenger.getRestricted({ name: controllerName, - allowedActions: ['NetworkController:getNetworkClientById'], + allowedActions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'PermissionController:hasPermissions', + ], allowedEvents: ['NetworkController:stateChange'], }); } jest.mock('@metamask/swappable-obj-proxy'); -const createEventEmitterProxyMock = jest.mocked(createEventEmitterProxy); -describe('SelectedNetworkController', () => { - beforeEach(() => { - createEventEmitterProxyMock.mockReset(); - }); +const setup = ({ + hasPermissions = true, + state, +}: { + hasPermissions?: boolean; + state?: SelectedNetworkControllerState; +} = {}) => { + 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(), + }; - it('can be instantiated with default values', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; + 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 }); + const controller = new SelectedNetworkController({ + messenger: selectedNetworkControllerMessenger, + state, + }); + return { + controller, + messenger, + mockProviderProxy, + mockBlockTrackerProxy, + createEventEmitterProxyMock, + }; +}; - const controller = new SelectedNetworkController(options); - expect(controller.state).toStrictEqual({ - domains: {}, - perDomainNetwork: false, +describe('SelectedNetworkController', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + describe('constructor', () => { + it('can be instantiated with default values', () => { + const { controller } = setup(); + expect(controller.state).toStrictEqual({ + domains: {}, + perDomainNetwork: false, + }); + }); + it('can be instantiated with a state', () => { + const { controller } = setup({ + state: { + perDomainNetwork: true, + domains: { networkClientId: 'goerli' }, + }, + }); + expect(controller.state).toStrictEqual({ + domains: { networkClientId: 'goerli' }, + perDomainNetwork: true, + }); }); }); 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 perDomainNetwork state is false', () => { + describe('when the requesting domain is not metamask', () => { + it('updates the networkClientId for domain in state', () => { + const { controller } = setup({ + state: { + perDomainNetwork: false, + domains: { + '1.com': 'mainnet', + '2.com': 'mainnet', + '3.com': 'mainnet', + }, + }, + }); + const domains = ['1.com', '2.com', '3.com']; + const networkClientIds = ['1', '2', '3']; + + domains.forEach((domain, i) => + controller.setNetworkClientIdForDomain(domain, networkClientIds[i]), + ); - 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); + 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 perDomainNetwork state is true', () => { + describe('when the requesting domain has existing permissions', () => { + it('sets the networkClientId for the passed in domain', () => { + const { controller } = setup({ + state: { perDomainNetwork: true, domains: {} }, + hasPermissions: 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: { perDomainNetwork: true, domains: {} }, + hasPermissions: 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); + // creates the proxy for the new domain + controller.setNetworkClientIdForDomain( + 'example.com', + initialNetworkClientId, + ); + const newNetworkClientId = 'abc'; - 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); - }); + // calls setTarget on the proxy + controller.setNetworkClientIdForDomain( + 'example.com', + newNetworkClientId, + ); - 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); + expect(mockProviderProxy.setTarget).toHaveBeenCalledWith( + expect.objectContaining({ sendAsync: expect.any(Function) }), + ); + expect(mockProviderProxy.setTarget).toHaveBeenCalledTimes(1); + }); + }); - 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); + 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: { perDomainNetwork: true, domains: {} }, + hasPermissions: false, + }); - expect(mockProviderProxy.setTarget).toHaveBeenCalledWith( - expect.objectContaining({ sendAsync: expect.any(Function) }), - ); - expect(mockProviderProxy.setTarget).toHaveBeenCalledTimes(2); + 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 perDomainNetwork state 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 perDomainNetwork state 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: { perDomainNetwork: true, domains: {} }, + hasPermissions: 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: { perDomainNetwork: true, domains: {} }, + hasPermissions: 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 perDomainNetwork is true', () => { + it('returns a proxy provider and block tracker when a networkClientId has been set for the requested domain', () => { + const { controller } = setup({ + state: { + perDomainNetwork: true, + domains: {}, + }, + }); + 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: { + perDomainNetwork: true, + domains: { + 'test.com': 'mainnet', + }, + }, + }); + 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: { + perDomainNetwork: true, + domains: {}, + }, + }); + + expect(() => { + controller.getProviderAndBlockTracker('test.com'); + }).toThrow('NetworkClientId has not been set for the requested domain'); + }); }); + describe('when perDomainNetwork is false', () => { + it('throws and error when a networkClientId has been been set for the requested domain', () => { + const { controller } = setup({ + state: { + perDomainNetwork: false, + domains: {}, + }, + }); - it('creates a new proxy provider and block tracker when there isnt one already', () => { - const options: SelectedNetworkControllerOptions = { - messenger: buildSelectedNetworkControllerMessenger(), - }; - const controller = new SelectedNetworkController(options); - expect( - controller.getNetworkClientIdForDomain('test.com'), - ).toBeUndefined(); - const result = controller.getProviderAndBlockTracker('test.com'); - expect(result).toBeDefined(); + expect(() => { + controller.getProviderAndBlockTracker('test.com'); + }).toThrow( + 'Provider and BlockTracker should be fetched from NetworkController when perDomainNetwork is false', + ); + }); }); }); 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); + describe('when toggling from false to true', () => { + it('should update perDomainNetwork state to true', () => { + const { controller } = setup({ + state: { + perDomainNetwork: false, + domains: {}, + }, + }); + controller.setPerDomainNetwork(true); + expect(controller.state.perDomainNetwork).toBe(true); + }); + }); + describe('when toggling from true to false', () => { + it('should update perDomainNetwork state to false', () => { + const { controller } = setup({ + state: { + perDomainNetwork: true, + domains: {}, + }, + }); + controller.setPerDomainNetwork(false); + expect(controller.state.perDomainNetwork).toBe(false); + }); }); }); }); 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) => { From 767718204cb5872e13c5a6bffad692c38f3defa4 Mon Sep 17 00:00:00 2001 From: legobeat <109787230+legobeat@users.noreply.github.com> Date: Thu, 22 Feb 2024 12:12:30 +0900 Subject: [PATCH 15/39] devDeps: @lavamoat/allow-scripts@^2.3.1->^3.0.2 (#3940) --- package.json | 5 +- packages/json-rpc-engine/package.json | 2 +- packages/keyring-controller/package.json | 2 +- yarn.lock | 600 ++++++++++++----------- 4 files changed, 322 insertions(+), 287 deletions(-) diff --git a/package.json b/package.json index d955485a21..38923904e2 100644 --- a/package.json +++ b/package.json @@ -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,9 +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/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 62a0310a18..e2902289e7 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -47,7 +47,7 @@ "@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/package.json b/packages/keyring-controller/package.json index eb1315ceb2..516f8ca65b 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 17c75c30e2..ab9cb412c0 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 @@ -1760,7 +1760,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 @@ -2146,7 +2146,7 @@ __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/safe-event-emitter": ^3.0.0 @@ -2236,7 +2236,7 @@ __metadata: "@ethereumjs/tx": ^4.2.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 @@ -3103,6 +3103,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" @@ -3112,6 +3125,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" @@ -3119,25 +3148,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 @@ -3258,13 +3302,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" @@ -3820,10 +3857,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 @@ -3894,7 +3931,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: @@ -3903,14 +3940,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 @@ -4052,23 +4087,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" @@ -4342,15 +4360,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 @@ -4587,23 +4605,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 @@ -4865,15 +4883,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" @@ -4930,13 +4939,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" @@ -5077,7 +5079,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: @@ -5208,13 +5210,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" @@ -5248,13 +5243,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" @@ -6502,22 +6490,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" @@ -6614,18 +6586,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 @@ -6804,13 +6776,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" @@ -6882,6 +6847,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" @@ -6916,14 +6890,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 @@ -6937,6 +6910,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" @@ -6958,15 +6941,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" @@ -7093,10 +7067,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 @@ -7479,6 +7456,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" @@ -7541,16 +7525,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 @@ -8216,6 +8200,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" @@ -8496,6 +8487,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" @@ -8514,20 +8512,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" @@ -8567,26 +8551,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 @@ -8728,12 +8708,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 @@ -8795,10 +8775,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 @@ -8835,7 +8815,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 @@ -8958,24 +8938,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 @@ -9003,14 +8982,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 @@ -9021,13 +9012,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" @@ -9046,18 +9070,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" @@ -9520,6 +9532,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" @@ -9534,6 +9553,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" @@ -9643,16 +9669,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" @@ -9803,7 +9819,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: @@ -9816,7 +9832,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: @@ -10012,14 +10028,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 @@ -10041,13 +10057,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" @@ -10207,24 +10216,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 @@ -10283,6 +10292,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" @@ -10290,7 +10309,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: @@ -10307,6 +10326,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" @@ -10361,7 +10387,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: @@ -11136,6 +11162,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" @@ -11320,7 +11356,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: @@ -11342,12 +11378,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 @@ -11518,33 +11556,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 From d646595b60f9ea0b238a019aa4d9a30cd7a63f12 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 22 Feb 2024 10:44:02 +0100 Subject: [PATCH 16/39] Bump `@metamask/rpc-errors` to `^6.2.0` (#3954) ## Explanation This bumps `@metamask/rpc-errors` to `^6.2.0` to solve a type error when using `@metamask/permission-controller`: ``` node_modules/@metamask/permission-controller/dist/errors.d.ts:22:107 - error TS7016: Could not find a declaration file for module '@metamask/rpc-errors/dist/utils'. '/Users/morten/Development/MetaMask/snaps/node_modules/@metamask/rpc-errors/dist/utils.js' implicitly has an 'any' type. ``` ## Changelog ### `@metamask/approval-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.1.0` to `^6.2.0`. ### `@metamask/assets-controllers` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.1.0` to `^6.2.0`. ### `@metamask/json-rpc-engine` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.1.0` to `^6.2.0`. ### `@metamask/network-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.1.0` to `^6.2.0`. ### `@metamask/permission-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.1.0` to `^6.2.0`. ### `@metamask/queued-request-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.1.0` to `^6.2.0`. ### `@metamask/rate-limit-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.1.0` to `^6.2.0`. ### `@metamask/signature-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.1.0` to `^6.2.0`. ### `@metamask/transaction-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.1.0` to `^6.2.0`. ### `@metamask/user-operation-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.1.0` to `^6.2.0`. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- packages/approval-controller/package.json | 2 +- packages/assets-controllers/package.json | 2 +- packages/json-rpc-engine/package.json | 2 +- packages/network-controller/package.json | 2 +- packages/permission-controller/package.json | 2 +- .../queued-request-controller/package.json | 2 +- packages/rate-limit-controller/package.json | 2 +- packages/signature-controller/package.json | 2 +- packages/transaction-controller/package.json | 2 +- .../user-operation-controller/package.json | 2 +- yarn.lock | 30 +++++++++---------- 11 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/approval-controller/package.json b/packages/approval-controller/package.json index 14f7fa15ee..8c752075e0 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.0", "@metamask/utils": "^8.3.0", "nanoid": "^3.1.31" }, diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index a92c4b7f79..d03a20d1ac 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -47,7 +47,7 @@ "@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.0", "@metamask/utils": "^8.3.0", "@types/uuid": "^8.3.0", "async-mutex": "^0.2.6", diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index e2902289e7..2c1e895af0 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -42,7 +42,7 @@ "test:watch": "jest --watch" }, "dependencies": { - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.0", "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^8.3.0" }, diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index cf95d9d126..26342763a8 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -38,7 +38,7 @@ "@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.0", "@metamask/swappable-obj-proxy": "^2.2.0", "@metamask/utils": "^8.3.0", "async-mutex": "^0.2.6", diff --git a/packages/permission-controller/package.json b/packages/permission-controller/package.json index de7e663cfc..ec45983fc0 100644 --- a/packages/permission-controller/package.json +++ b/packages/permission-controller/package.json @@ -34,7 +34,7 @@ "@metamask/base-controller": "^4.1.1", "@metamask/controller-utils": "^8.0.3", "@metamask/json-rpc-engine": "^7.3.2", - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.0", "@metamask/utils": "^8.3.0", "@types/deep-freeze-strict": "^1.1.0", "deep-freeze-strict": "^1.1.1", diff --git a/packages/queued-request-controller/package.json b/packages/queued-request-controller/package.json index 4ac47bda6f..9849d352cd 100644 --- a/packages/queued-request-controller/package.json +++ b/packages/queued-request-controller/package.json @@ -34,7 +34,7 @@ "@metamask/base-controller": "^4.1.1", "@metamask/controller-utils": "^8.0.3", "@metamask/json-rpc-engine": "^7.3.2", - "@metamask/rpc-errors": "^6.1.0", + "@metamask/rpc-errors": "^6.2.0", "@metamask/swappable-obj-proxy": "^2.2.0", "@metamask/utils": "^8.3.0" }, diff --git a/packages/rate-limit-controller/package.json b/packages/rate-limit-controller/package.json index 597232c433..49853999ed 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.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index 1d096ecead..6601fd6086 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -37,7 +37,7 @@ "@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.0", "@metamask/utils": "^8.3.0", "ethereumjs-util": "^7.0.10", "lodash": "^4.17.21" diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 3ce40e2a07..6f209066f6 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -41,7 +41,7 @@ "@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.0", "@metamask/utils": "^8.3.0", "async-mutex": "^0.2.6", "eth-method-registry": "^4.0.0", diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 4146ac2c7b..eb07cb5edc 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -40,7 +40,7 @@ "@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/rpc-errors": "^6.2.0", "@metamask/transaction-controller": "^23.0.0", "@metamask/utils": "^8.3.0", "ethereumjs-util": "^7.0.10", diff --git a/yarn.lock b/yarn.lock index ab9cb412c0..fac7b808b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1558,7 +1558,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.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 @@ -1595,7 +1595,7 @@ __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.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 "@types/lodash": ^4.14.191 @@ -2148,7 +2148,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": ^3.0.2 "@metamask/auto-changelog": ^3.4.4 - "@metamask/rpc-errors": ^6.1.0 + "@metamask/rpc-errors": ^6.2.0 "@metamask/safe-event-emitter": ^3.0.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2343,7 +2343,7 @@ __metadata: "@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.0 "@metamask/swappable-obj-proxy": ^2.2.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2442,7 +2442,7 @@ __metadata: "@metamask/base-controller": ^4.1.1 "@metamask/controller-utils": ^8.0.3 "@metamask/json-rpc-engine": ^7.3.2 - "@metamask/rpc-errors": ^6.1.0 + "@metamask/rpc-errors": ^6.2.0 "@metamask/utils": ^8.3.0 "@types/deep-freeze-strict": ^1.1.0 "@types/jest": ^27.4.1 @@ -2589,7 +2589,7 @@ __metadata: "@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/rpc-errors": ^6.2.0 "@metamask/selected-network-controller": ^7.0.1 "@metamask/swappable-obj-proxy": ^2.2.0 "@metamask/utils": ^8.3.0 @@ -2617,7 +2617,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.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 jest: ^27.5.1 @@ -2628,13 +2628,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.0": + version: 6.2.0 + resolution: "@metamask/rpc-errors@npm:6.2.0" dependencies: - "@metamask/utils": ^8.1.0 + "@metamask/utils": ^8.3.0 fast-safe-stringify: ^2.0.6 - checksum: 9f4821d804e2fcaa8987b0958d02c6d829b7c7db49740c811cb593f381d0c4b00dabb7f1802907f1b2f6126f7c0d83ec34219183d29650f5d24df014ac72906a + checksum: 1db3065d3f391916ef958531f4e1101a9c3abd0794f446a8b938165bd6e2ddb706f174ad4fdd5a04bfe4eb6b2bb4dd638957cb9bc321f6835cb0431264327087 languageName: node linkType: hard @@ -2699,7 +2699,7 @@ __metadata: "@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.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 @@ -2908,7 +2908,7 @@ __metadata: "@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.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 "@types/node": ^16.18.54 @@ -2948,7 +2948,7 @@ __metadata: "@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/rpc-errors": ^6.2.0 "@metamask/transaction-controller": ^23.0.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 From 50962a735e7d09aa76501282d61c77d6c6623b8e Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Thu, 22 Feb 2024 13:30:08 +0100 Subject: [PATCH 17/39] fix(preferences-controller): skip sync when locking (#3946) --- packages/preferences-controller/jest.config.js | 8 ++++---- .../src/PreferencesController.test.ts | 7 +++++++ .../preferences-controller/src/PreferencesController.ts | 8 +++++++- 3 files changed, 18 insertions(+), 5 deletions(-) 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/src/PreferencesController.test.ts b/packages/preferences-controller/src/PreferencesController.test.ts index 4d4947f0ec..81f8442db6 100644 --- a/packages/preferences-controller/src/PreferencesController.test.ts +++ b/packages/preferences-controller/src/PreferencesController.test.ts @@ -325,6 +325,13 @@ describe('PreferencesController', () => { expect(controller.state.selectedAddress).toBe('0x00'); }); + it('should throw error when syncing identities with empty array', () => { + const controller = setupPreferencesController(); + expect(() => { + controller.syncIdentities([]); + }).toThrow('Expected non-empty array of addresses'); + }); + it('should add new identities', () => { const controller = setupPreferencesController(); controller.updateIdentities(['0x00', '0x01']); diff --git a/packages/preferences-controller/src/PreferencesController.ts b/packages/preferences-controller/src/PreferencesController.ts index 060b162c38..dce4a683f1 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)); + } }, ); } @@ -323,6 +325,10 @@ export class PreferencesController extends BaseController< * @deprecated This will be removed in a future release */ syncIdentities(addresses: string[]) { + if (!addresses.length) { + throw new Error('Expected non-empty array of addresses'); + } + addresses = addresses.map((address: string) => toChecksumHexAddress(address), ); From 7af430d47efe52b75984f165c7f453b4cb86c8b0 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 22 Feb 2024 09:41:08 -0600 Subject: [PATCH 18/39] Fix `SelectedNetworkController` state corruption when networkClients are removed (#3926) Fixes: https://github.com/MetaMask/MetaMask-planning/issues/1964 ## `@metamask/selected-network-controller` ### CHANGED - The `SelectedNetworkController` now listens for `networkConfiguration` removal events on the `NetworkController` and if a removed `networkClientId` matches the set networkClientId for any `domains` in its state, it updates them to the globally selected networkClientId and repoints the proxies accordingly. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../src/SelectedNetworkController.ts | 22 ++++++++++ .../tests/SelectedNetworkController.test.ts | 40 ++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/selected-network-controller/src/SelectedNetworkController.ts b/packages/selected-network-controller/src/SelectedNetworkController.ts index f51497bba1..20cccbbbd2 100644 --- a/packages/selected-network-controller/src/SelectedNetworkController.ts +++ b/packages/selected-network-controller/src/SelectedNetworkController.ts @@ -135,6 +135,28 @@ export class SelectedNetworkController extends BaseController< state, }); this.#registerMessageHandlers(); + + 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 { diff --git a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts index 61d41ae4f0..9a202639e1 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts @@ -118,7 +118,10 @@ const setup = ({ }); const messenger = buildMessenger(); const selectedNetworkControllerMessenger = - buildSelectedNetworkControllerMessenger({ messenger, hasPermissions }); + buildSelectedNetworkControllerMessenger({ + messenger, + hasPermissions, + }); const controller = new SelectedNetworkController({ messenger: selectedNetworkControllerMessenger, state, @@ -158,6 +161,41 @@ describe('SelectedNetworkController', () => { }); }); + 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: { + perDomainNetwork: true, + domains: { + metamask: 'goerli', + 'example.com': 'test-network-client-id', + 'test.com': 'test-network-client-id', + }, + }, + }); + + 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', () => { afterEach(() => { jest.clearAllMocks(); From 75f5b86574b3a98bc0ce45469f7679a5405a45a2 Mon Sep 17 00:00:00 2001 From: witmicko Date: Thu, 22 Feb 2024 17:15:15 +0000 Subject: [PATCH 19/39] Enabling the MetaMask Security Code Scanner (#3929) ## Required Action Prior to merging this pull request, please ensure the following has been completed: - [x] The lines specifying `branches` correctly specifies this repository's default branch (usually `main`). - [ ] Any paths you would like to ignore have been added to the `paths_ignored` configuration option (see [setup](https://github.com/MetaMask/Security-Code-Scanner/blob/main/README.md#setup)) - [x] Any existing CodeQL configuration has been disabled. ## What is the Security Code Scanner? This pull request enables the [MetaMask Security Code Scanner](https://github.com/metamask/Security-Code-Scanner/) GitHub Action. This action runs on each pull request, and will flag potential vulnerabilities as a review comment. It will also scan this repository's default branch, and log any findings in this repository's [Code Scanning Alerts Tab](https://github.com/metamask/core/security/code-scanning). Screenshot 2024-02-12 at 9 19 05 PM The action itself runs various static analysis engines behind the scenes. Currently, it is only running GitHub's CodeQL engine. For this reason, we recommend disabling any existing CodeQL configuration your repository may have. ## How do I interact with the tool? Every finding raised by the Security Code Scanner will present context behind the potential vulnerability identified, and allow the developer to fix, or dismiss it. The finding will automatically be dismissed by pushing a commit that fixes the identified issue, or by manually dismissing the alert using the button in GitHub's UI. If dismissing an alert manually, please add any additional context surrounding the reason for dismissal, as this informs our decision to disable, or improve any poor performing rules. Screenshot 2024-02-12 at 8 41 46 PM For more configuration options, please review the tool's [README](https://github.com/MetaMask/Security-Code-Scanner/blob/main/README.md). For any additional questions, please reach out to `@mm-application-security` slack. --------- Co-authored-by: Jongsun Suh --- .github/workflows/security-code-scanner.yml | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/security-code-scanner.yml 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 }} From aa3a16b7fc9884cf5ed6346079f9bbce39902dd0 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 22 Feb 2024 11:50:33 -0600 Subject: [PATCH 20/39] Release/118.0.0 (#3958) # @metamask/selected-network-controller ## [8.0.0] ### Changed - **BREAKING:** `setNetworkClientIdForDomain` now throws an error if passed `metamask` as its first (`domain`) argument ([#3908](https://github.com/MetaMask/core/pull/3908)). - **BREAKING:** `setNetworkClientIdForDomain` now includes a check that the requesting `domain` has already been granted permissions in the `PermissionsController` before adding it to `domains` state and throws an error if the domain does not have permissions ([#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 is not in domains state ([#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 and 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 when not already set. Now if no `networkClientId` is already set for the requesting origin, the middleware will not add the origin to `domains` state but will add the `networkClientId` currently set for the `selectedNetworkClient` from the `NetworkController` to the request object ([#3908](https://github.com/MetaMask/core/pull/3908)). ### Fixed - The `SelectedNetworkController` now listens for `networkConfiguration` removal events on the `NetworkController` and if a removed `networkClientId` matches the set `networkClientId` for any domains in its state, it updates them to the globally selected `networkClientId` and repoints the proxies accordingly. ([#3926](https://github.com/MetaMask/core/pull/3926)) --------- Co-authored-by: jiexi Co-authored-by: Elliot Winkler --- package.json | 2 +- .../queued-request-controller/CHANGELOG.md | 9 +++++++- .../queued-request-controller/package.json | 6 ++--- .../selected-network-controller/CHANGELOG.md | 22 ++++++++++++++++++- .../selected-network-controller/package.json | 2 +- yarn.lock | 6 ++--- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 38923904e2..59b23e9844 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "117.0.0", + "version": "118.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/queued-request-controller/CHANGELOG.md b/packages/queued-request-controller/CHANGELOG.md index 6d5158dfbd..88d679c420 100644 --- a/packages/queued-request-controller/CHANGELOG.md +++ b/packages/queued-request-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -88,7 +94,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 9849d352cd..b9eabed60d 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", @@ -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": ">=16.0.0" diff --git a/packages/selected-network-controller/CHANGELOG.md b/packages/selected-network-controller/CHANGELOG.md index db26260f76..c660a4fdb6 100644 --- a/packages/selected-network-controller/CHANGELOG.md +++ b/packages/selected-network-controller/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -106,7 +125,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 e2473295ac..dbe3cc81af 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", diff --git a/yarn.lock b/yarn.lock index fac7b808b9..e271eef04a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2590,7 +2590,7 @@ __metadata: "@metamask/json-rpc-engine": ^7.3.2 "@metamask/network-controller": ^17.2.0 "@metamask/rpc-errors": ^6.2.0 - "@metamask/selected-network-controller": ^7.0.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 @@ -2607,7 +2607,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 @@ -2662,7 +2662,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: From 2e8012e6e3c6e55beda93f14dceb3b1f7428bded Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Thu, 22 Feb 2024 14:15:37 -0500 Subject: [PATCH 21/39] [keyring-controller] Remove reliance upon the `PreferencesController` (#3853) ## Explanation Removes all `PreferencesController` callbacks from `KeyringController` class, methods, and tests. ## References - Closes #3699 ## Changelog ## `@metamask/keyring-controller` ### Removed - **BREAKING:** Remove `updateIdentities`, `syncIdentities`, `setSelectedAddress`, `setAccountLabel` from constructor options of `KeyringController` class. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- packages/keyring-controller/CHANGELOG.md | 7 + packages/keyring-controller/jest.config.js | 6 +- .../src/KeyringController.test.ts | 234 ++++++------------ .../src/KeyringController.ts | 50 ---- 4 files changed, 92 insertions(+), 205 deletions(-) diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index 36ec3bf3fa..7105f0a045 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index c10b793a72..a2202a44f8 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -85,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(); }); @@ -101,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); }); @@ -113,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 () => { @@ -205,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 () => { @@ -240,7 +209,7 @@ describe('KeyringController', () => { keyringBuilderFactory(MockShallowGetAccountsKeyring), ], }, - async ({ controller, initialState, preferences }) => { + async ({ controller }) => { const mockKeyring = (await controller.addNewKeyring( MockShallowGetAccountsKeyring.type, )) as Keyring; @@ -253,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); }, ); }); @@ -267,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 () => { @@ -335,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 () => { @@ -475,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, @@ -960,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); }); }); }); @@ -1034,13 +980,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); }); }); @@ -3117,18 +3062,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; @@ -3201,17 +3139,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) { @@ -3219,7 +3150,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 068f6b7a84..ab30deef37 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -218,10 +218,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 }; @@ -495,14 +491,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[]; @@ -523,10 +511,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. @@ -535,10 +519,6 @@ export class KeyringController extends BaseController< */ constructor(options: KeyringControllerOptions) { const { - syncIdentities, - updateIdentities, - setSelectedAddress, - setAccountLabel, encryptor = encryptorUtils, keyringBuilders, messenger, @@ -576,11 +556,6 @@ export class KeyringController extends BaseController< assertIsExportableKeyEncryptor(encryptor); } - this.syncIdentities = syncIdentities; - this.updateIdentities = updateIdentities; - this.setSelectedAddress = setSelectedAddress; - this.setAccountLabel = setAccountLabel; - this.#registerMessageHandlers(); } @@ -621,8 +596,6 @@ export class KeyringController extends BaseController< ); await this.verifySeedPhrase(); - this.updateIdentities(await this.getAccounts()); - return { keyringState: this.#getMemState(), addedAccountAddress, @@ -661,8 +634,6 @@ export class KeyringController extends BaseController< ); assertIsStrictHexString(addedAccountAddress); - this.updateIdentities(await this.getAccounts()); - return addedAccountAddress; } @@ -703,7 +674,6 @@ export class KeyringController extends BaseController< } try { - this.updateIdentities([]); await this.#createNewVaultWithKeyring(password, { type: KeyringTypes.hd, opts: { @@ -711,7 +681,6 @@ export class KeyringController extends BaseController< numberOfAccounts: 1, }, }); - this.updateIdentities(await this.getAccounts()); return this.#getMemState(); } finally { releaseLock(); @@ -732,7 +701,6 @@ export class KeyringController extends BaseController< await this.#createNewVaultWithKeyring(password, { type: KeyringTypes.hd, }); - this.updateIdentities(await this.getAccounts()); } return this.#getMemState(); } finally { @@ -1095,8 +1063,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], @@ -1379,8 +1345,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 @@ -1388,7 +1352,6 @@ export class KeyringController extends BaseController< this.#subscribeToQRKeyringEvents(qrKeyring); } - await this.syncIdentities(accounts); return this.#getMemState(); } @@ -1467,7 +1430,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 { @@ -1540,22 +1502,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(); } @@ -1577,7 +1528,6 @@ export class KeyringController extends BaseController< const removedAccounts = allAccounts.filter( (address: string) => !remainingAccounts.includes(address), ); - this.updateIdentities(remainingAccounts); await this.persistAllKeyrings(); return { removedAccounts, remainingAccounts }; } From bcd5ae03473f6a4abfdf5ce63a8de44836c305a0 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Thu, 22 Feb 2024 21:07:47 +0100 Subject: [PATCH 22/39] Refactor PermissionLogController tests (#3937) ## Explanation This pull request is designed to align the tests for `PermissionLogController` with the guidelines outlined in our contributor documentation. The objective is to refactor these tests in accordance with best practices recommended for unit testing, as detailed in the provided documentation [link](https://github.com/MetaMask/contributor-docs/blob/main/docs/unit-testing.md). ## References * Fixes #1827 ## Changes - Discontinued the use of beforeEach for the majority of the tests, with exceptions made for certain tests that utilize fake timers. Now, each test individually initializes a new controller and middleware within its own scope, enhancing test isolation and clarity. - Split long test scenarios for more focused test cases. ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../src/PermissionLogController.ts | 3 - .../tests/PermissionLogController.test.ts | 809 +++++++++--------- .../tests/helpers.ts | 2 - 3 files changed, 416 insertions(+), 398 deletions(-) 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. */ From b8b0c566b1955dc8b2f4f49a88f8d786132df3ce Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 22 Feb 2024 16:46:45 -0700 Subject: [PATCH 23/39] Add workflow to check presence of DO-NOT-MERGE (#3960) The `DO-NOT-MERGE` label is designed to prevent other people from merging a PR (perhaps because it is blocking another PR or for some other reason). In reality, however, this is merely an alert, and it is still possible for a PR which has this label to be merged. This commit adds a GitHub workflow which runs when opening a PR or changing the labels for a PR and fails if the PR has a `DO-NOT-MERGE` label. Once this workflow is in place, the `ensure-block-pr-labels-absent` check can be added to the branch protection settings to truly enforce that a PR cannot be merged while the label is in place. --- The extension has a similar check: https://github.com/MetaMask/metamask-extension/blob/275523a24e4f496384943f107369810cd93aa86e/.github/workflows/check-pr-labels.yml. I simplified the workflow. --- .../ensure-blocking-pr-labels-absent.yml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/ensure-blocking-pr-labels-absent.yml 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'." + ); + } From e9fc9f1978ba787edb6214f6295b4921a006b5b0 Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Thu, 22 Feb 2024 16:25:16 -0800 Subject: [PATCH 24/39] fix: price api cache control (#3939) ## Explanation Sets `Cache-Control: no-cache` on price API requests. See: https://consensys.slack.com/archives/C065W3877E3/p1708468886411439?thread_ts=1708466257.782319&cid=C065W3877E3 ## References ## Changelog ### `@metamask/assets-controllers` - **CHANGED**: HTTP requests to the price API now use the `no-cache` directive. ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../assets-controllers/src/token-prices-service/codefi-v2.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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( ( From ca4609d6eedaeeb1b3bca1dba6eabb196b42e001 Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Fri, 23 Feb 2024 16:34:55 -0500 Subject: [PATCH 25/39] [transaction-controller] Rename test directory to tests (#3950) ## Explanation Simple repo maintenance: fixing inconsistent naming for test directory ## Changelog N/A --- .../src/TransactionControllerIntegration.test.ts | 4 ++-- .../src/helpers/EtherscanRemoteTransactionSource.test.ts | 2 +- .../transaction-controller/{test => tests}/EtherscanMocks.ts | 0 .../{test => tests}/JsonRpcRequestMocks.ts | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/transaction-controller/{test => tests}/EtherscanMocks.ts (100%) rename packages/transaction-controller/{test => tests}/JsonRpcRequestMocks.ts (100%) diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index bffbb78e4a..c54f5adaf4 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -22,7 +22,7 @@ import { ETHERSCAN_TRANSACTION_RESPONSE_MOCK, ETHERSCAN_TOKEN_TRANSACTION_MOCK, ETHERSCAN_TRANSACTION_SUCCESS_MOCK, -} from '../test/EtherscanMocks'; +} from '../tests/EtherscanMocks'; import { buildEthGasPriceRequestMock, buildEthBlockNumberRequestMock, @@ -33,7 +33,7 @@ import { buildEthGetBlockByHashRequestMock, buildEthSendRawTransactionRequestMock, buildEthGetTransactionReceiptRequestMock, -} from '../test/JsonRpcRequestMocks'; +} from '../tests/JsonRpcRequestMocks'; import { TransactionController } from './TransactionController'; import type { TransactionMeta } from './types'; import { TransactionStatus, TransactionType } from './types'; diff --git a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts index d70c394bd8..ebddb6b370 100644 --- a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts +++ b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.test.ts @@ -11,7 +11,7 @@ import { EXPECTED_NORMALISED_TOKEN_TRANSACTION, ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_ERROR_MOCK, ETHERSCAN_TRANSACTION_RESPONSE_ERROR_MOCK, -} from '../../test/EtherscanMocks'; +} from '../../tests/EtherscanMocks'; import { CHAIN_IDS } from '../constants'; import { fetchEtherscanTokenTransactions, diff --git a/packages/transaction-controller/test/EtherscanMocks.ts b/packages/transaction-controller/tests/EtherscanMocks.ts similarity index 100% rename from packages/transaction-controller/test/EtherscanMocks.ts rename to packages/transaction-controller/tests/EtherscanMocks.ts diff --git a/packages/transaction-controller/test/JsonRpcRequestMocks.ts b/packages/transaction-controller/tests/JsonRpcRequestMocks.ts similarity index 100% rename from packages/transaction-controller/test/JsonRpcRequestMocks.ts rename to packages/transaction-controller/tests/JsonRpcRequestMocks.ts From 5c542a1d7bb0ce9134c6521e704188e404aacc8c Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Fri, 23 Feb 2024 16:42:31 -0500 Subject: [PATCH 26/39] Update eslint ignore patterns (#3953) ## Explanation Simple repo maintenance: update ignore patterns in eslint config ## Changelog N/A --- .eslintrc.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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: [ { From 40acc6c81bfcf2b3995a9c5e94cce069f41f95f6 Mon Sep 17 00:00:00 2001 From: legobeat <109787230+legobeat@users.noreply.github.com> Date: Sat, 24 Feb 2024 09:46:07 +0900 Subject: [PATCH 27/39] deps: upgrade from ethereumjs-util (#3943) --- package.json | 2 - packages/accounts-controller/package.json | 3 +- .../src/AccountsController.ts | 5 +- packages/accounts-controller/src/utils.ts | 5 +- packages/assets-controllers/package.json | 4 +- .../src/AssetsContractController.ts | 2 +- .../src/NftController.test.ts | 2 +- .../assets-controllers/src/NftController.ts | 5 +- .../src/Standards/ERC20Standard.ts | 4 +- .../NftStandards/ERC1155/ERC1155Standard.ts | 2 +- .../src/TokenBalancesController.test.ts | 2 +- .../src/TokenDetectionController.test.ts | 2 +- .../src/TokenRatesController.test.ts | 6 +-- packages/assets-controllers/src/assetsUtil.ts | 5 +- packages/controller-utils/jest.config.js | 2 +- packages/controller-utils/jest.environment.js | 18 ++++++++ packages/controller-utils/package.json | 2 +- packages/controller-utils/src/siwe.ts | 9 ++-- packages/controller-utils/src/util.test.ts | 2 +- packages/controller-utils/src/util.ts | 46 ++++++++++--------- packages/gas-fee-controller/package.json | 3 +- .../src/fetchBlockFeeHistory.test.ts | 2 +- .../src/fetchBlockFeeHistory.ts | 2 +- .../fetchGasEstimatesViaEthFeeHistory.test.ts | 2 +- ...teGasFeeEstimatesForPriorityLevels.test.ts | 2 +- ...lculateGasFeeEstimatesForPriorityLevels.ts | 2 +- .../medianOf.ts | 2 +- .../types.ts | 2 +- packages/gas-fee-controller/src/gas-util.ts | 2 +- packages/keyring-controller/package.json | 7 +-- .../src/KeyringController.test.ts | 10 ++-- .../src/KeyringController.ts | 17 +++---- packages/message-manager/package.json | 1 - packages/message-manager/src/utils.ts | 8 ++-- packages/signature-controller/package.json | 1 - .../src/SignatureController.ts | 4 +- packages/transaction-controller/package.json | 4 +- .../src/TransactionController.ts | 7 +-- .../src/gas-flows/LineaGasFeeFlow.ts | 2 +- .../EtherscanRemoteTransactionSource.ts | 2 +- .../src/utils/gas-fees.ts | 5 +- .../transaction-controller/src/utils/gas.ts | 11 ++--- .../transaction-controller/src/utils/utils.ts | 37 ++++++++------- .../user-operation-controller/package.json | 2 +- .../src/UserOperationController.ts | 6 +-- .../src/utils/gas-fees.ts | 4 +- .../src/utils/gas.ts | 5 +- .../src/utils/transaction.ts | 13 +++--- yarn.lock | 32 +++++++------ 49 files changed, 175 insertions(+), 150 deletions(-) create mode 100644 packages/controller-utils/jest.environment.js diff --git a/package.json b/package.json index 59b23e9844..927d205420 100644 --- a/package.json +++ b/package.json @@ -92,8 +92,6 @@ "@lavamoat/preinstall-always-fail": false, "@keystonehq/bc-ur-registry-eth>hdkey>secp256k1": true, "babel-runtime>core-js": false, - "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 0e6b608faf..2f8c461048 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.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 3a599c0970..9888b31c2d 100644 --- a/packages/accounts-controller/src/utils.ts +++ b/packages/accounts-controller/src/utils.ts @@ -1,5 +1,6 @@ +import { toBuffer } from '@ethereumjs/util'; import { isCustodyKeyring, KeyringTypes } from '@metamask/keyring-controller'; -import { sha256FromString } from 'ethereumjs-util'; +import { sha256 } from 'ethereum-cryptography/sha256'; import { v4 as uuid } from 'uuid'; /** @@ -50,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/assets-controllers/package.json b/packages/assets-controllers/package.json index d03a20d1ac..677f50225b 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", @@ -49,10 +50,11 @@ "@metamask/preferences-controller": "^7.0.0", "@metamask/rpc-errors": "^6.2.0", "@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/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 971bd3d49b..f2e8f653f1 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -19,7 +19,7 @@ import { 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'; 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.ts b/packages/assets-controllers/src/assetsUtil.ts index a36b681f46..f76b13ba2e 100644 --- a/packages/assets-controllers/src/assetsUtil.ts +++ b/packages/assets-controllers/src/assetsUtil.ts @@ -4,7 +4,8 @@ import { 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 { @@ -240,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/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 9acfe9eaa4..50c115bf0a 100644 --- a/packages/controller-utils/package.json +++ b/packages/controller-utils/package.json @@ -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/gas-fee-controller/package.json b/packages/gas-fee-controller/package.json index 853c9bcb01..13d171bc8e 100644 --- a/packages/gas-fee-controller/package.json +++ b/packages/gas-fee-controller/package.json @@ -38,8 +38,9 @@ "@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/keyring-controller/package.json b/packages/keyring-controller/package.json index 516f8ca65b..d32e460276 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" }, @@ -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 a2202a44f8..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'; @@ -931,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( @@ -1218,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, @@ -2289,7 +2287,7 @@ describe('KeyringController', () => { ), ); - const data = bufferToHex( + const data = bytesToHex( Buffer.from('Example `personal_sign` message', 'utf8'), ); const qrKeyring = signProcessKeyringController.state.keyrings.find( diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index ab30deef37..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'; @@ -1027,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 { @@ -1044,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; @@ -1054,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}'`); diff --git a/packages/message-manager/package.json b/packages/message-manager/package.json index f6f87932a0..fa08815781 100644 --- a/packages/message-manager/package.json +++ b/packages/message-manager/package.json @@ -36,7 +36,6 @@ "@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/signature-controller/package.json b/packages/signature-controller/package.json index 6601fd6086..24d948470d 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -39,7 +39,6 @@ "@metamask/message-manager": "^7.3.8", "@metamask/rpc-errors": "^6.2.0", "@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/package.json b/packages/transaction-controller/package.json index 6f209066f6..4d0c6323c1 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -33,6 +33,7 @@ "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", @@ -44,8 +45,8 @@ "@metamask/rpc-errors": "^6.2.0", "@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,6 +56,7 @@ "@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", diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 0823a4ce63..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, @@ -34,9 +35,9 @@ import type { 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'; @@ -1698,7 +1699,7 @@ export class TransactionController extends BaseControllerV1< : undefined; const nonce = nonceLock - ? addHexPrefix(nonceLock.nextNonce.toString(16)) + ? add0x(nonceLock.nextNonce.toString(16)) : initialTx.nonce; if (nonceLock) { @@ -2718,7 +2719,7 @@ export class TransactionController extends BaseControllerV1< continue; } - transactionMeta[key] = addHexPrefix(value.toString(16)); + transactionMeta[key] = add0x(value.toString(16)); } } diff --git a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts index 290ae7bed4..1b1ff0574f 100644 --- a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts @@ -9,7 +9,7 @@ import type EthQuery from '@metamask/eth-query'; import type { GasFeeEstimates as GasFeeControllerEstimates } from '@metamask/gas-fee-controller'; import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; import { createModuleLogger, type Hex } from '@metamask/utils'; -import type { BN } from 'ethereumjs-util'; +import type BN from 'bn.js'; import { projectLogger } from '../logger'; import type { diff --git a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts index 5274d6c9b4..651eec110e 100644 --- a/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts +++ b/packages/transaction-controller/src/helpers/EtherscanRemoteTransactionSource.ts @@ -1,7 +1,7 @@ import { BNToHex } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import { Mutex } from 'async-mutex'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import { v1 as random } from 'uuid'; import { ETHERSCAN_SUPPORTED_NETWORKS } from '../constants'; diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index cb9c9e067c..882af0a49a 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -12,8 +12,7 @@ import type { GasFeeState, } from '@metamask/gas-fee-controller'; import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; -import { addHexPrefix } from 'ethereumjs-util'; +import { add0x, createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../logger'; import type { @@ -312,7 +311,7 @@ async function getSuggestedGasFees( const gasPriceDecimal = (await query(ethQuery, 'gasPrice')) as number; const gasPrice = gasPriceDecimal - ? addHexPrefix(gasPriceDecimal.toString(16)) + ? add0x(gasPriceDecimal.toString(16)) : undefined; return { gasPrice }; diff --git a/packages/transaction-controller/src/utils/gas.ts b/packages/transaction-controller/src/utils/gas.ts index 4fc306e218..d0095481f9 100644 --- a/packages/transaction-controller/src/utils/gas.ts +++ b/packages/transaction-controller/src/utils/gas.ts @@ -8,8 +8,7 @@ import { } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; -import { addHexPrefix } from 'ethereumjs-util'; +import { add0x, createModuleLogger } from '@metamask/utils'; import { GAS_BUFFER_CHAIN_OVERRIDES } from '../constants'; import { projectLogger } from '../logger'; @@ -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; } 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/user-operation-controller/package.json b/packages/user-operation-controller/package.json index eb07cb5edc..8a3e56705a 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -43,7 +43,7 @@ "@metamask/rpc-errors": "^6.2.0", "@metamask/transaction-controller": "^23.0.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", 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/yarn.lock b/yarn.lock index e271eef04a..522bc1b0e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 @@ -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 @@ -1597,14 +1599,15 @@ __metadata: "@metamask/preferences-controller": ^7.0.0 "@metamask/rpc-errors": ^6.2.0 "@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 @@ -1733,6 +1736,7 @@ __metadata: 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 @@ -1742,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 @@ -2123,11 +2126,12 @@ __metadata: "@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 @@ -2234,6 +2238,7 @@ __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": ^3.0.2 @@ -2250,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 @@ -2294,7 +2298,6 @@ __metadata: "@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 @@ -2703,7 +2706,6 @@ __metadata: "@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 @@ -2898,6 +2900,7 @@ __metadata: "@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 @@ -2910,12 +2913,13 @@ __metadata: "@metamask/network-controller": ^17.2.0 "@metamask/rpc-errors": ^6.2.0 "@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 @@ -2952,8 +2956,8 @@ __metadata: "@metamask/transaction-controller": ^23.0.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 @@ -3371,12 +3375,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 @@ -6030,7 +6034,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: From 33cff0f5be6a12e9b3ad06c2178c48fe781c563d Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Mon, 26 Feb 2024 11:21:37 +0100 Subject: [PATCH 28/39] Bump `@metamask/rpc-errors` to `^6.2.1` (#3970) ## Explanation This bumps `@metamask/rpc-errors` to `^6.2.1`. This is a more permanent fix for a type issue (initially solved by #3954). ## Changelog ### `@metamask/approval-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.2.0` to `^6.2.1`. ### `@metamask/assets-controllers` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.2.0` to `^6.2.1`. ### `@metamask/json-rpc-engine` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.2.0` to `^6.2.1`. ### `@metamask/network-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.2.0` to `^6.2.1`. ### `@metamask/permission-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.2.0` to `^6.2.1`. ### `@metamask/queued-request-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.2.0` to `^6.2.1`. ### `@metamask/rate-limit-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.2.0` to `^6.2.1`. ### `@metamask/signature-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.2.0` to `^6.2.1`. ### `@metamask/transaction-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.2.0` to `^6.2.1`. ### `@metamask/user-operation-controller` - **CHANGED**: Bump `@metamask/rpc-errors` from `^6.2.0` to `^6.2.1`. ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- packages/approval-controller/package.json | 2 +- packages/assets-controllers/package.json | 2 +- packages/json-rpc-engine/package.json | 2 +- packages/network-controller/package.json | 2 +- packages/permission-controller/package.json | 2 +- .../queued-request-controller/package.json | 2 +- packages/rate-limit-controller/package.json | 2 +- packages/signature-controller/package.json | 2 +- packages/transaction-controller/package.json | 2 +- .../user-operation-controller/package.json | 2 +- yarn.lock | 28 +++++++++---------- 11 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/approval-controller/package.json b/packages/approval-controller/package.json index 8c752075e0..595615312c 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.2.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/utils": "^8.3.0", "nanoid": "^3.1.31" }, diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 677f50225b..c1a5fb41fb 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -48,7 +48,7 @@ "@metamask/network-controller": "^17.2.0", "@metamask/polling-controller": "^5.0.0", "@metamask/preferences-controller": "^7.0.0", - "@metamask/rpc-errors": "^6.2.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/utils": "^8.3.0", "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 2c1e895af0..1b550619f7 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -42,7 +42,7 @@ "test:watch": "jest --watch" }, "dependencies": { - "@metamask/rpc-errors": "^6.2.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^8.3.0" }, diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 26342763a8..d11899ef96 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -38,7 +38,7 @@ "@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.2.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/package.json b/packages/permission-controller/package.json index ec45983fc0..b61512f5f4 100644 --- a/packages/permission-controller/package.json +++ b/packages/permission-controller/package.json @@ -34,7 +34,7 @@ "@metamask/base-controller": "^4.1.1", "@metamask/controller-utils": "^8.0.3", "@metamask/json-rpc-engine": "^7.3.2", - "@metamask/rpc-errors": "^6.2.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/queued-request-controller/package.json b/packages/queued-request-controller/package.json index b9eabed60d..e405162a24 100644 --- a/packages/queued-request-controller/package.json +++ b/packages/queued-request-controller/package.json @@ -34,7 +34,7 @@ "@metamask/base-controller": "^4.1.1", "@metamask/controller-utils": "^8.0.3", "@metamask/json-rpc-engine": "^7.3.2", - "@metamask/rpc-errors": "^6.2.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/swappable-obj-proxy": "^2.2.0", "@metamask/utils": "^8.3.0" }, diff --git a/packages/rate-limit-controller/package.json b/packages/rate-limit-controller/package.json index 49853999ed..ddcbce1b90 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.2.0" + "@metamask/rpc-errors": "^6.2.1" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index 24d948470d..7dd707175c 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -37,7 +37,7 @@ "@metamask/keyring-controller": "^12.2.0", "@metamask/logging-controller": "^2.0.2", "@metamask/message-manager": "^7.3.8", - "@metamask/rpc-errors": "^6.2.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/utils": "^8.3.0", "lodash": "^4.17.21" }, diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 4d0c6323c1..26ccf19ce4 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -42,7 +42,7 @@ "@metamask/gas-fee-controller": "^13.0.1", "@metamask/metamask-eth-abis": "^3.0.0", "@metamask/network-controller": "^17.2.0", - "@metamask/rpc-errors": "^6.2.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/utils": "^8.3.0", "async-mutex": "^0.2.6", "bn.js": "^5.2.1", diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 8a3e56705a..36be3b8ed1 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -40,7 +40,7 @@ "@metamask/keyring-controller": "^12.2.0", "@metamask/network-controller": "^17.2.0", "@metamask/polling-controller": "^5.0.0", - "@metamask/rpc-errors": "^6.2.0", + "@metamask/rpc-errors": "^6.2.1", "@metamask/transaction-controller": "^23.0.0", "@metamask/utils": "^8.3.0", "bn.js": "^5.2.1", diff --git a/yarn.lock b/yarn.lock index 522bc1b0e5..b874f22523 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1559,7 +1559,7 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/rpc-errors": ^6.2.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 @@ -1597,7 +1597,7 @@ __metadata: "@metamask/network-controller": ^17.2.0 "@metamask/polling-controller": ^5.0.0 "@metamask/preferences-controller": ^7.0.0 - "@metamask/rpc-errors": ^6.2.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/utils": ^8.3.0 "@types/bn.js": ^5.1.5 "@types/jest": ^27.4.1 @@ -2152,7 +2152,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": ^3.0.2 "@metamask/auto-changelog": ^3.4.4 - "@metamask/rpc-errors": ^6.2.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/safe-event-emitter": ^3.0.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2346,7 +2346,7 @@ __metadata: "@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.2.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/swappable-obj-proxy": ^2.2.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 @@ -2445,7 +2445,7 @@ __metadata: "@metamask/base-controller": ^4.1.1 "@metamask/controller-utils": ^8.0.3 "@metamask/json-rpc-engine": ^7.3.2 - "@metamask/rpc-errors": ^6.2.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/utils": ^8.3.0 "@types/deep-freeze-strict": ^1.1.0 "@types/jest": ^27.4.1 @@ -2592,7 +2592,7 @@ __metadata: "@metamask/controller-utils": ^8.0.3 "@metamask/json-rpc-engine": ^7.3.2 "@metamask/network-controller": ^17.2.0 - "@metamask/rpc-errors": ^6.2.0 + "@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 @@ -2620,7 +2620,7 @@ __metadata: dependencies: "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 - "@metamask/rpc-errors": ^6.2.0 + "@metamask/rpc-errors": ^6.2.1 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 jest: ^27.5.1 @@ -2631,13 +2631,13 @@ __metadata: languageName: unknown linkType: soft -"@metamask/rpc-errors@npm:^6.0.0, @metamask/rpc-errors@npm:^6.1.0, @metamask/rpc-errors@npm:^6.2.0": - version: 6.2.0 - resolution: "@metamask/rpc-errors@npm:6.2.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.3.0 fast-safe-stringify: ^2.0.6 - checksum: 1db3065d3f391916ef958531f4e1101a9c3abd0794f446a8b938165bd6e2ddb706f174ad4fdd5a04bfe4eb6b2bb4dd638957cb9bc321f6835cb0431264327087 + checksum: a9223c3cb9ab05734ea0dda990597f90a7cdb143efa0c026b1a970f2094fe5fa3c341ed39b1e7623be13a96b98fb2c697ef51a2e2b87d8f048114841d35ee0a9 languageName: node linkType: hard @@ -2702,7 +2702,7 @@ __metadata: "@metamask/keyring-controller": ^12.2.0 "@metamask/logging-controller": ^2.0.2 "@metamask/message-manager": ^7.3.8 - "@metamask/rpc-errors": ^6.2.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 deepmerge: ^4.2.2 @@ -2911,7 +2911,7 @@ __metadata: "@metamask/gas-fee-controller": ^13.0.1 "@metamask/metamask-eth-abis": ^3.0.0 "@metamask/network-controller": ^17.2.0 - "@metamask/rpc-errors": ^6.2.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/utils": ^8.3.0 "@types/bn.js": ^5.1.5 "@types/jest": ^27.4.1 @@ -2952,7 +2952,7 @@ __metadata: "@metamask/keyring-controller": ^12.2.0 "@metamask/network-controller": ^17.2.0 "@metamask/polling-controller": ^5.0.0 - "@metamask/rpc-errors": ^6.2.0 + "@metamask/rpc-errors": ^6.2.1 "@metamask/transaction-controller": ^23.0.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 From a794c31f15bacb9d893fe56f15a84e35a21c426b Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Mon, 26 Feb 2024 11:31:21 +0100 Subject: [PATCH 29/39] Release 119.0.0 (#3971) ## Explanation This is the release candidate for `119.0.0`. It only bumps `permission-controller`. --- package.json | 2 +- packages/permission-controller/CHANGELOG.md | 9 ++++++++- packages/permission-controller/package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 927d205420..46cb9d637e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "118.0.0", + "version": "119.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/permission-controller/CHANGELOG.md b/packages/permission-controller/CHANGELOG.md index c1f9a562a1..c50a628fff 100644 --- a/packages/permission-controller/CHANGELOG.md +++ b/packages/permission-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -179,7 +185,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 b61512f5f4..326cc29992 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", From 42de49bfbfb05d029871bc0b8b0f8217e6635f93 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 26 Feb 2024 16:15:15 +0000 Subject: [PATCH 30/39] Use fixed multipliers to generate Linea priority fees (#3948) Update the `LineaGasFeeFlow` to generate the priority fees using static multipliers. Add a `gasFeeEstimatesLoaded` property to enable the clients to know when the first gas fee estimates update has completed. --- .../transaction-controller/jest.config.js | 8 +- .../src/gas-flows/LineaGasFeeFlow.test.ts | 63 ++------ .../src/gas-flows/LineaGasFeeFlow.ts | 140 +++++------------- .../src/helpers/GasFeePoller.test.ts | 13 +- .../src/helpers/GasFeePoller.ts | 33 +++-- packages/transaction-controller/src/types.ts | 9 +- 6 files changed, 87 insertions(+), 179 deletions(-) diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js index d09576381e..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: 91.8, - functions: 98.58, - lines: 98.91, - statements: 98.92, + branches: 91.79, + functions: 98.56, + lines: 98.9, + statements: 98.91, }, }, diff --git a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts index 735faec26f..01281c9721 100644 --- a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts @@ -1,7 +1,6 @@ import { query } 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 { CHAIN_IDS } from '../constants'; import type { @@ -29,28 +28,10 @@ const TRANSACTION_META_MOCK: TransactionMeta = { }; const LINEA_RESPONSE_MOCK = { - baseFeePerGas: '0x1', - priorityFeePerGas: '0x2', + baseFeePerGas: '0x111111111', + priorityFeePerGas: '0x222222222', }; -const GAS_FEE_CONTROLLER_RESPONSE_MOCK: GasFeeState = { - gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, - gasFeeEstimates: { - low: { - suggestedMaxFeePerGas: '1', - suggestedMaxPriorityFeePerGas: '2', - }, - medium: { - suggestedMaxFeePerGas: '3', - suggestedMaxPriorityFeePerGas: '4', - }, - high: { - suggestedMaxFeePerGas: '5', - suggestedMaxPriorityFeePerGas: '6', - }, - }, -} as GasFeeState; - const RESPONSE_MOCK: GasFeeFlowResponse = { estimates: { low: { @@ -79,11 +60,6 @@ describe('LineaGasFeeFlow', () => { beforeEach(() => { jest.resetAllMocks(); - getGasFeeControllerEstimatesMock = jest.fn(); - getGasFeeControllerEstimatesMock.mockResolvedValue( - GAS_FEE_CONTROLLER_RESPONSE_MOCK, - ); - request = { ethQuery: {} as EthQuery, getGasFeeControllerEstimates: getGasFeeControllerEstimatesMock, @@ -110,7 +86,7 @@ describe('LineaGasFeeFlow', () => { }); describe('getGasFees', () => { - it('returns priority fees using custom RPC method and gas fee controller estimate differences', async () => { + it('returns priority fees using custom RPC method and static priority fee multipliers', async () => { const flow = new LineaGasFeeFlow(); const response = await flow.getGasFees(request); @@ -118,39 +94,20 @@ describe('LineaGasFeeFlow', () => { Object.values(response.estimates).map( (level) => level.maxPriorityFeePerGas, ), - ).toStrictEqual(['0x2', '0x77359402', '0xee6b2802']); + ).toStrictEqual([ + LINEA_RESPONSE_MOCK.priorityFeePerGas, + '0x23a3d70a3', + '0x25658bf25', + ]); }); - it('returns max fees using custom RPC method and base fee multipliers', async () => { + 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(['0x3', '0x77359403', '0xee6b2803']); - }); - - it('uses default flow if gas fee estimate type is not fee market', async () => { - jest - .spyOn(DefaultGasFeeFlow.prototype, 'getGasFees') - .mockResolvedValue(RESPONSE_MOCK); - - const defaultGasFeeFlowGetGasFeesMock = jest.mocked( - DefaultGasFeeFlow.prototype.getGasFees, - ); - - getGasFeeControllerEstimatesMock.mockResolvedValue({ - ...GAS_FEE_CONTROLLER_RESPONSE_MOCK, - gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, - } as GasFeeState); - - const flow = new LineaGasFeeFlow(); - const response = await flow.getGasFees(request); - - expect(response).toStrictEqual(RESPONSE_MOCK); - - expect(defaultGasFeeFlowGetGasFeesMock).toHaveBeenCalledTimes(1); - expect(defaultGasFeeFlowGetGasFeesMock).toHaveBeenCalledWith(request); + ).toStrictEqual(['0x333333333', '0x3a7ae1479', '0x42428f5c1']); }); it('uses default flow if error', async () => { diff --git a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts index 1b1ff0574f..a6ebeed574 100644 --- a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts @@ -1,13 +1,5 @@ -import { - ChainId, - gweiDecToWEIBN, - hexToBN, - query, - toHex, -} from '@metamask/controller-utils'; +import { ChainId, hexToBN, query, toHex } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import type { GasFeeEstimates as GasFeeControllerEstimates } from '@metamask/gas-fee-controller'; -import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; import { createModuleLogger, type Hex } from '@metamask/utils'; import type BN from 'bn.js'; @@ -33,8 +25,6 @@ type FeesByLevel = { const log = createModuleLogger(projectLogger, 'linea-gas-fee-flow'); -const ONE_GWEI_IN_WEI = 1e9; - const LINEA_CHAIN_IDS: Hex[] = [ ChainId['linea-mainnet'], ChainId['linea-goerli'], @@ -46,10 +36,16 @@ const BASE_FEE_MULTIPLIERS = { 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. - * - The GasFeeController to provide the priority fee deltas based on recent block analysis. + * - Static multipliers to increase the base and priority fees. */ export class LineaGasFeeFlow implements GasFeeFlow { matchesTransaction(transactionMeta: TransactionMeta): boolean { @@ -68,8 +64,7 @@ export class LineaGasFeeFlow implements GasFeeFlow { async #getLineaGasFees( request: GasFeeFlowRequest, ): Promise { - const { ethQuery, getGasFeeControllerEstimates, transactionMeta } = request; - const { networkClientId } = transactionMeta; + const { ethQuery, transactionMeta } = request; const lineaResponse = await this.#getLineaResponse( transactionMeta, @@ -78,32 +73,23 @@ export class LineaGasFeeFlow implements GasFeeFlow { log('Received Linea response', lineaResponse); - const gasFeeControllerEstimates = await getGasFeeControllerEstimates({ - networkClientId, - }); - - log('Received gas fee controller estimates', gasFeeControllerEstimates); - - if ( - gasFeeControllerEstimates.gasEstimateType !== - GAS_ESTIMATE_TYPES.FEE_MARKET - ) { - throw new Error('No gas fee estimates available'); - } + const baseFees = this.#getValuesFromMultipliers( + lineaResponse.baseFeePerGas, + BASE_FEE_MULTIPLIERS, + ); - const baseFees = this.#getBaseFees(lineaResponse); + log('Generated base fees', this.#feesToString(baseFees)); - const priorityFees = this.#getPriorityFees( - lineaResponse, - gasFeeControllerEstimates.gasFeeEstimates, + const priorityFees = this.#getValuesFromMultipliers( + lineaResponse.priorityFeePerGas, + PRIORITY_FEE_MULTIPLIERS, ); + log('Generated priority fees', this.#feesToString(priorityFees)); + const maxFees = this.#getMaxFees(baseFees, priorityFees); - this.#logDifferencesToGasFeeController( - maxFees, - gasFeeControllerEstimates.gasFeeEstimates, - ); + log('Generated max fees', this.#feesToString(maxFees)); const estimates = Object.values(GasFeeEstimateLevel).reduce( (result, level) => ({ @@ -129,64 +115,28 @@ export class LineaGasFeeFlow implements GasFeeFlow { to: transactionMeta.txParams.to, value: transactionMeta.txParams.value, input: transactionMeta.txParams.data, + // Required in request but no impact on response. gasPrice: '0x100000000', }, ]); } - #getBaseFees(lineaResponse: LineaEstimateGasResponse): FeesByLevel { - const baseFeeLow = hexToBN(lineaResponse.baseFeePerGas); - const baseFeeMedium = baseFeeLow.muln(BASE_FEE_MULTIPLIERS.medium); - const baseFeeHigh = baseFeeLow.muln(BASE_FEE_MULTIPLIERS.high); - - return { - low: baseFeeLow, - medium: baseFeeMedium, - high: baseFeeHigh, - }; - } - - #getPriorityFees( - lineaResponse: LineaEstimateGasResponse, - gasFeeEstimates: GasFeeControllerEstimates, + #getValuesFromMultipliers( + value: Hex, + multipliers: { low: number; medium: number; high: number }, ): FeesByLevel { - const mediumPriorityIncrease = this.#getPriorityLevelDifference( - gasFeeEstimates, - GasFeeEstimateLevel.medium, - GasFeeEstimateLevel.low, - ); - - const highPriorityIncrease = this.#getPriorityLevelDifference( - gasFeeEstimates, - GasFeeEstimateLevel.high, - GasFeeEstimateLevel.medium, - ); - - const priorityFeeLow = hexToBN(lineaResponse.priorityFeePerGas); - const priorityFeeMedium = priorityFeeLow.add(mediumPriorityIncrease); - const priorityFeeHigh = priorityFeeMedium.add(highPriorityIncrease); + const base = hexToBN(value); + const low = base.muln(multipliers.low); + const medium = base.muln(multipliers.medium); + const high = base.muln(multipliers.high); return { - low: priorityFeeLow, - medium: priorityFeeMedium, - high: priorityFeeHigh, + low, + medium, + high, }; } - #getPriorityLevelDifference( - gasFeeEstimates: GasFeeControllerEstimates, - firstLevel: GasFeeEstimateLevel, - secondLevel: GasFeeEstimateLevel, - ): BN { - return gweiDecToWEIBN( - gasFeeEstimates[firstLevel].suggestedMaxPriorityFeePerGas, - ).sub( - gweiDecToWEIBN( - gasFeeEstimates[secondLevel].suggestedMaxPriorityFeePerGas, - ), - ); - } - #getMaxFees( baseFees: Record, priorityFees: Record, @@ -198,31 +148,9 @@ export class LineaGasFeeFlow implements GasFeeFlow { }; } - #logDifferencesToGasFeeController( - maxFees: FeesByLevel, - gasFeeControllerEstimates: GasFeeControllerEstimates, - ) { - const calculateDifference = (level: GasFeeEstimateLevel) => { - const newMaxFeeWeiDec = maxFees[level].toNumber(); - const newMaxFeeGweiDec = newMaxFeeWeiDec / ONE_GWEI_IN_WEI; - - const oldMaxFeeGweiDec = parseFloat( - gasFeeControllerEstimates[level].suggestedMaxFeePerGas, - ); - - const percentDifference = (newMaxFeeGweiDec / oldMaxFeeGweiDec - 1) * 100; - - /* istanbul ignore next */ - return `${percentDifference > 0 ? '+' : ''}${percentDifference.toFixed( - 2, - )}%`; - }; - - log( - 'Difference to gas fee controller', - calculateDifference(GasFeeEstimateLevel.low), - calculateDifference(GasFeeEstimateLevel.medium), - calculateDifference(GasFeeEstimateLevel.high), + #feesToString(fees: FeesByLevel) { + return Object.values(GasFeeEstimateLevel).map((level) => + fees[level].toString(10), ); } } diff --git a/packages/transaction-controller/src/helpers/GasFeePoller.test.ts b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts index 590bc794a2..06485103fb 100644 --- a/packages/transaction-controller/src/helpers/GasFeePoller.test.ts +++ b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts @@ -92,6 +92,7 @@ describe('GasFeePoller', () => { expect(listener).toHaveBeenCalledWith({ ...TRANSACTION_META_MOCK, gasFeeEstimates: GAS_FEE_FLOW_RESPONSE_MOCK.estimates, + gasFeeEstimatesLoaded: true, }); }); @@ -174,11 +175,15 @@ describe('GasFeePoller', () => { expect(listener).toHaveBeenCalledTimes(0); }); - it('no gas fee flow matches transaction', async () => { + 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); @@ -188,11 +193,15 @@ describe('GasFeePoller', () => { expect(listener).toHaveBeenCalledTimes(0); }); - it('gas fee flow throws', async () => { + 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); diff --git a/packages/transaction-controller/src/helpers/GasFeePoller.ts b/packages/transaction-controller/src/helpers/GasFeePoller.ts index 83f1d8ba43..52343389b5 100644 --- a/packages/transaction-controller/src/helpers/GasFeePoller.ts +++ b/packages/transaction-controller/src/helpers/GasFeePoller.ts @@ -6,7 +6,7 @@ import EventEmitter from 'events'; import type { NetworkClientId } from '../../../network-controller/src'; import { projectLogger } from '../logger'; -import type { GasFeeFlow, GasFeeFlowRequest } from '../types'; +import type { GasFeeEstimates, GasFeeFlow, GasFeeFlowRequest } from '../types'; import { TransactionStatus, type TransactionMeta } from '../types'; import { getGasFeeFlow } from '../utils/gas-flow'; @@ -125,28 +125,39 @@ export class GasFeePoller { const gasFeeFlow = getGasFeeFlow(transactionMeta, this.#gasFeeFlows); if (!gasFeeFlow) { - log('Skipping update as no gas fee flow found', transactionMeta.id); - - return; + log('No gas fee flow found', transactionMeta.id); + } else { + log( + 'Found gas fee flow', + gasFeeFlow.constructor.name, + transactionMeta.id, + ); } - log('Found gas fee flow', gasFeeFlow.constructor.name, transactionMeta.id); - const request: GasFeeFlowRequest = { ethQuery, getGasFeeControllerEstimates: this.#getGasFeeControllerEstimates, transactionMeta, }; - try { - const response = await gasFeeFlow.getGasFees(request); + let gasFeeEstimates: GasFeeEstimates | undefined; - transactionMeta.gasFeeEstimates = response.estimates; - } catch (error) { - log('Failed to get suggested gas fees', transactionMeta.id, error); + 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', { diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 4f92019ed0..0d7a9fa564 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -181,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. */ @@ -335,9 +341,6 @@ type TransactionMetaBase = { */ submittedTime?: number; - /** Alternate EIP-1559 gas fee estimates for multiple priority levels. */ - gasFeeEstimates?: GasFeeEstimates; - /** * The symbol of the token being swapped. */ From ec115dd50e4b3832bc95230ce2d6be04302f3930 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 26 Feb 2024 16:57:42 +0000 Subject: [PATCH 31/39] Release 120.0.0 (#3978) --- package.json | 2 +- packages/transaction-controller/CHANGELOG.md | 14 +++++++++++++- packages/transaction-controller/package.json | 2 +- packages/user-operation-controller/package.json | 2 +- yarn.lock | 4 ++-- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 46cb9d637e..3cd463a28c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "119.0.0", + "version": "120.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 36c068a253..1b2d5bb272 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 @@ -532,7 +543,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@23.0.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 diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 26ccf19ce4..554490c014 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "23.0.0", + "version": "23.1.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 36be3b8ed1..9f1a4aea20 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -41,7 +41,7 @@ "@metamask/network-controller": "^17.2.0", "@metamask/polling-controller": "^5.0.0", "@metamask/rpc-errors": "^6.2.1", - "@metamask/transaction-controller": "^23.0.0", + "@metamask/transaction-controller": "^23.1.0", "@metamask/utils": "^8.3.0", "bn.js": "^5.2.1", "immer": "^9.0.6", diff --git a/yarn.lock b/yarn.lock index b874f22523..a6d71e8b27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2893,7 +2893,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@^23.0.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: @@ -2953,7 +2953,7 @@ __metadata: "@metamask/network-controller": ^17.2.0 "@metamask/polling-controller": ^5.0.0 "@metamask/rpc-errors": ^6.2.1 - "@metamask/transaction-controller": ^23.0.0 + "@metamask/transaction-controller": ^23.1.0 "@metamask/utils": ^8.3.0 "@types/jest": ^27.4.1 bn.js: ^5.2.1 From 67af7d4a486d1c9932e03d4bd509e3164e07fced Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Mon, 26 Feb 2024 20:23:14 -0330 Subject: [PATCH 32/39] fix(queued-request-controller): Add missing type exports (#3984) ## Explanation These two types were added in #3919, but these exports were accidentally omitted. ## References This change was missing from #3919 ## Changelog This change was documented already in #3919 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- packages/queued-request-controller/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/queued-request-controller/src/index.ts b/packages/queued-request-controller/src/index.ts index 27d6cffe09..4e3d8384ee 100644 --- a/packages/queued-request-controller/src/index.ts +++ b/packages/queued-request-controller/src/index.ts @@ -2,6 +2,8 @@ export type { QueuedRequestControllerState, QueuedRequestControllerCountChangedEvent, QueuedRequestControllerEnqueueRequestAction, + QueuedRequestControllerGetStateAction, + QueuedRequestControllerStateChangeEvent, QueuedRequestControllerEvents, QueuedRequestControllerActions, QueuedRequestControllerMessenger, From 26ae5912b6ffa858aaa4bd04c96ecd4d0a9f0bfa Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Mon, 26 Feb 2024 20:29:05 -0330 Subject: [PATCH 33/39] chore(queued-request-controller): Remove `countChanged` event (#3985) ## Explanation The event `QueuedRequestController:countChanged` and the method `length` have been removed. Both the event and method were deprecated as part of #3919, which also provided an alternative by adding the queued request count to the controller state. ## References Related to #3919 ## Changelog ### `@metamask/queued-request-controller` #### Removed - **BREAKING**: Remove the `QueuedRequestController:countChanged` event - The number of queued requests is now tracked in controller state, as the `queuedRequestCount` property. Use the `QueuedRequestController:stateChange` event to be notified of count changes instead. - **BREAKING**: Remove the `length` method - The number of queued requests is now tracked in controller state, as the `queuedRequestCount` property. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../src/QueuedRequestController.test.ts | 95 +------------------ .../src/QueuedRequestController.ts | 31 +----- .../queued-request-controller/src/index.ts | 1 - 3 files changed, 2 insertions(+), 125 deletions(-) diff --git a/packages/queued-request-controller/src/QueuedRequestController.test.ts b/packages/queued-request-controller/src/QueuedRequestController.test.ts index a14bcd3032..3f3e1ff631 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.test.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.test.ts @@ -139,7 +139,7 @@ describe('QueuedRequestController', () => { await expect(() => controller.enqueueRequest(requestWithError), ).rejects.toThrow(new Error('Request failed')); - expect(controller.length()).toBe(0); + expect(controller.state.queuedRequestCount).toBe(0); }); it('correctly updates the request queue count upon failure', async () => { @@ -191,97 +191,4 @@ describe('QueuedRequestController', () => { }); }); }); - - 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); - }); - - 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); - }); - }); }); diff --git a/packages/queued-request-controller/src/QueuedRequestController.ts b/packages/queued-request-controller/src/QueuedRequestController.ts index 5f6ff7d9ea..8b3e446337 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.ts @@ -27,7 +27,6 @@ export type QueuedRequestControllerEnqueueRequestAction = { }; export const QueuedRequestControllerEventTypes = { - countChanged: `${controllerName}:countChanged` as const, stateChange: `${controllerName}:stateChange` as const, }; @@ -37,19 +36,8 @@ export type QueuedRequestControllerStateChangeEvent = QueuedRequestControllerState >; -/** - * This event is fired when the number of queued requests changes. - * - * @deprecated Use the `QueuedRequestController:stateChange` event instead - */ -export type QueuedRequestControllerCountChangedEvent = { - type: typeof QueuedRequestControllerEventTypes.countChanged; - payload: [number]; -}; - export type QueuedRequestControllerEvents = - | QueuedRequestControllerCountChangedEvent - | QueuedRequestControllerStateChangeEvent; + QueuedRequestControllerStateChangeEvent; export type QueuedRequestControllerActions = | QueuedRequestControllerGetStateAction @@ -113,27 +101,10 @@ 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. - * - * @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. - * @deprecated This method is deprecated; use `state.queuedRequestCount` directly instead. - */ - length() { - return this.state.queuedRequestCount; - } - #updateCount(change: -1 | 1) { this.update((state) => { state.queuedRequestCount += change; }); - this.messagingSystem.publish( - 'QueuedRequestController:countChanged', - this.state.queuedRequestCount, - ); } /** diff --git a/packages/queued-request-controller/src/index.ts b/packages/queued-request-controller/src/index.ts index 4e3d8384ee..169880f07c 100644 --- a/packages/queued-request-controller/src/index.ts +++ b/packages/queued-request-controller/src/index.ts @@ -1,6 +1,5 @@ export type { QueuedRequestControllerState, - QueuedRequestControllerCountChangedEvent, QueuedRequestControllerEnqueueRequestAction, QueuedRequestControllerGetStateAction, QueuedRequestControllerStateChangeEvent, From eda5b9dfa87ab9418b77709a0a020a7ffefe9d92 Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Mon, 26 Feb 2024 19:08:49 -0500 Subject: [PATCH 34/39] [base-controller] Fix `any` usage in `BaseControllerV1` (#3959) ## Explanation - For runtime property assignment, use `as unknown as` instead of `as any`. - Change the types for `BaseControllerV1` class fields `initialConfig`, `initialState` from `C`, `S` to `Partial`, `Partial`. - Initial user-supplied constructor options do not need to be complete `C`, `S` objects, since `internal{Config,State}` will be populated with `default{Config,State}`. - For empty objects, prefer no type assertions or `as never` (`never` is assignable to all types). - Fix code written based on outdated TypeScript limitation. - Generic spread expressions for object literals are supported by TypeScript: https://github.com/microsoft/TypeScript/pull/28234 ## References - Closes #3715 ## Changelog ### [`@metamask/base-controller`](https://github.com/MetaMask/core/pull/3959/files#diff-a8212838da15b445582e5622bd4cc8195e4c52bcf87210af8074555f806706a9) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../base-controller/src/BaseControllerV1.ts | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) 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; } } } From 1d78bb5a4f69a58d1fe4ea12c9414856a5da18a3 Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Mon, 26 Feb 2024 19:13:58 -0500 Subject: [PATCH 35/39] [token-detection-controller] Apply recent `DetectTokensController` updates (#3923) ## Explanation As a preparatory step for fully replacing the extension `DetectTokensController` with the consolidated core repo `TokenDetectionController`, `TokenDetectionController` needs to be updated with changes made to the extension `DetectTokensController` since #1813 was closed. #### Diff of `DetectTokensController` state - [MetaMask/metamask-extension@`5d285f7be5f7be981995dfa725aad97d81cc990a..85cd1c89039e900b452edb704ec37e9ccbd3e76a`#diff-323d0cf464](https://github.com/MetaMask/metamask-extension/compare/5d285f7be5f7be981995dfa725aad97d81cc990a..85cd1c89039e900b452edb704ec37e9ccbd3e76a#diff-323d0cf46498be3850b971474905354ea5ccf7fa13745ad1e6eba59c5b586830) ### Differences from extension `DetectTokensController` - Refactors logic for retrieving `chainId`, `networkClientId` into `this.#getCorrectChainIdAndNetworkClientId` - Uses `getNetworkConfigurationByNetworkClientId` action instead of `getNetworkClientById` to retrieve `chainId`. - If `networkClientId` is not supplied to the method, or it's supplied but `getNetworkConfigurationByNetworkClientId` returns `undefined`, finds `chainId` from `providerConfig`. - `detectTokens` replaces `detectNewTokens` - `detectTokens` accepts options object `{ selectedAddress, networkClientId }` instead of `{ selectedAddress, chainId, networkClientId }`. - Does not throw error if `getBalancesInSingleCall` fails. Also does not exit early -- continues looping. - Passes lists of full `Token` types to `TokensController:addDetectedTokens` instead of objects containing only `{ address, decimals, symbol }`. - `#trackMetaMetricsEvents` is a private method instead of protected. - Passes string literals instead of extension shared constants into `_trackMetaMetricsEvent`. ## References - Partially implements #3916 - Blocking #3918 - Changes adopted from: - https://github.com/MetaMask/metamask-extension/pull/22898 - https://github.com/MetaMask/metamask-extension/pull/22814 - https://github.com/MetaMask/core/pull/3914 - https://github.com/MetaMask/metamask-extension/pull/21437 - Blocking (Followed by) #3938 ## Changelog ### [`@metamask/assets-controllers`](https://github.com/MetaMask/core/pull/3923/files#diff-ee47d03d53776b8dd530799a8047f5e32e36e35765620aeb50b294adc3339fab) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: jiexi --- packages/assets-controllers/CHANGELOG.md | 6 +- packages/assets-controllers/jest.config.js | 6 +- .../src/TokenDetectionController.test.ts | 96 ++++++-- .../src/TokenDetectionController.ts | 219 ++++++++++-------- 4 files changed, 210 insertions(+), 117 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 9c4e2b57c0..2c6d29df14 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 761ed600dd..ecc2975e25 100644 --- a/packages/assets-controllers/jest.config.js +++ b/packages/assets-controllers/jest.config.js @@ -17,9 +17,9 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 88.8, - functions: 96.71, - lines: 97.34, + branches: 88.58, + functions: 96.98, + lines: 97.35, statements: 97.4, }, }, diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index f2e8f653f1..81d82ce848 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -8,12 +8,14 @@ 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, @@ -138,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', @@ -338,7 +343,16 @@ describe('TokenDetectionController', () => { selectedAddress, }, }, - async ({ controller, mockTokenListGetState, callActionSpy }) => { + async ({ + controller, + mockGetProviderConfig, + mockTokenListGetState, + callActionSpy, + }) => { + mockGetProviderConfig({ + chainId: '0x89', + } as unknown as ProviderConfig); + mockTokenListGetState({ ...getDefaultTokenListState(), tokensChainsCache: { @@ -1748,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' }], ]); }, ); @@ -1793,7 +1807,7 @@ describe('TokenDetectionController', () => { }); await controller.detectTokens({ networkClientId: NetworkType.goerli, - accountAddress: selectedAddress, + selectedAddress, }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1833,7 +1847,7 @@ describe('TokenDetectionController', () => { }); await controller.detectTokens({ networkClientId: NetworkType.mainnet, - accountAddress: selectedAddress, + selectedAddress, }); expect(callActionSpy).toHaveBeenLastCalledWith( 'TokensController:addDetectedTokens', @@ -1896,7 +1910,7 @@ describe('TokenDetectionController', () => { await controller.detectTokens({ networkClientId: NetworkType.mainnet, - accountAddress: selectedAddress, + selectedAddress, }); expect(callActionSpy).toHaveBeenCalledWith( @@ -1951,7 +1965,7 @@ describe('TokenDetectionController', () => { await controller.detectTokens({ networkClientId: NetworkType.mainnet, - accountAddress: selectedAddress, + selectedAddress, }); expect(mockTrackMetaMetricsEvent).toHaveBeenCalledWith({ @@ -1983,10 +1997,14 @@ function getTokensPath(chainId: Hex) { type WithControllerCallback = ({ controller, + mockGetSelectedAccount, mockKeyringGetState, mockTokensGetState, mockTokenListGetState, mockPreferencesGetState, + mockFindNetworkClientIdByChainId, + mockGetNetworkConfigurationByNetworkClientId, + mockGetProviderConfig, callActionSpy, triggerKeyringUnlock, triggerKeyringLock, @@ -1996,13 +2014,18 @@ 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; + mockGetProviderConfig: (config: ProviderConfig) => void; callActionSpy: jest.SpyInstance; triggerKeyringUnlock: () => void; triggerKeyringLock: () => void; @@ -2039,6 +2062,13 @@ async function withController( 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', @@ -2046,6 +2076,11 @@ async function withController( isUnlocked: isKeyringUnlocked ?? true, } as KeyringControllerState), ); + const mockFindNetworkClientIdByChainId = jest.fn(); + controllerMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + mockFindNetworkClientIdByChainId.mockReturnValue(NetworkType.mainnet), + ); const mockGetNetworkConfigurationByNetworkClientId = jest.fn< ReturnType, Parameters @@ -2053,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', @@ -2096,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); }, @@ -2108,13 +2154,21 @@ 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, ); }, + 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 adb7503a3c..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,114 +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; + !this.#isDetectionEnabledFromPreferences && + chainIdAgainstWhichToDetect === ChainId.mainnet; const { tokensChainsCache } = this.messagingSystem.call( 'TokenListController:getState', ); - const tokenList = tokensChainsCache[chainId]?.data ?? {}; - + const tokenList = + tokensChainsCache[chainIdAgainstWhichToDetect]?.data ?? {}; const tokenListUsed = isTokenDetectionInactiveInMainnet ? STATIC_MAINNET_TOKEN_LIST : tokenList; const { allTokens, allDetectedTokens, allIgnoredTokens } = this.messagingSystem.call('TokensController:getState'); - const tokens = allTokens[chainId]?.[selectedAddress] ?? []; - const detectedTokens = allDetectedTokens[chainId]?.[selectedAddress] ?? []; - const ignoredTokens = allIgnoredTokens[chainId]?.[selectedAddress] ?? []; - + 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', @@ -547,10 +584,10 @@ export class TokenDetectionController extends StaticIntervalPollingController< }); await this.messagingSystem.call( 'TokensController:addDetectedTokens', - tokensToAdd, + tokensWithBalance, { - selectedAddress, - chainId, + selectedAddress: addressAgainstWhichToDetect, + chainId: chainIdAgainstWhichToDetect, }, ); } From d95851d3ddfb64a3b5da9fb54c8553370363315b Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Tue, 27 Feb 2024 10:15:11 -0330 Subject: [PATCH 36/39] refactor(queued-request-controller): Refactor request processing (#3968) ## Explanation The `QueuedRequestMiddleware` and `QueuedRequestController` work together to determine when to queue or execute a request, and whether to trigger a network switch. Previously the network switch step was performed by the middleware. It has been refactored to happen inside the controller instead. This change results in various breaking changes to both the controller and middleware, but when used together they should behave exactly the same as before. This change was made as part of the effort to refactor the RPC queue to batch requests by origin (#3763). It was easier to accomplish that if the network switch step happened inside the controller, because the controller would have additional state needed to make that decision for batches rather than individual requests. ## References Relates to #3763 ## Changelog ### `@metamask/queued-request-controller` #### Changed - **BREAKING:** The `QueuedRequestController` method `enqueueRequest` is now responsible for switching the network before processing a request, rather than the `QueuedRequestMiddleware` - Functionally the behavior is the same: before processing each request, we compare the request network client with the current selected network client, and we switch the current selected network client if necessary. - The `QueuedRequestController` messenger has four additional allowed actions: - `NetworkController:getState` - `NetworkController:setActiveNetwork` - `NetworkController:getNetworkConfigurationByNetworkClientId` - `ApprovalController:addRequest` - The `QueuedRequestController` method `enqueueRequest` now takes one additional parameter, the request object - The `QueuedRequestMiddleware` no longer has a controller messenger. Instead it takes the `enqueueRequest` method as a parameter. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: jiexi Co-authored-by: legobeat <109787230+legobeat@users.noreply.github.com> --- .../src/QueuedRequestController.test.ts | 339 ++++++++- .../src/QueuedRequestController.ts | 114 ++- .../src/QueuedRequestMiddleware.test.ts | 696 +++++------------- .../src/QueuedRequestMiddleware.ts | 184 ++--- .../queued-request-controller/src/index.ts | 2 +- .../queued-request-controller/src/types.ts | 7 + 6 files changed, 641 insertions(+), 701 deletions(-) create mode 100644 packages/queued-request-controller/src/types.ts diff --git a/packages/queued-request-controller/src/QueuedRequestController.test.ts b/packages/queued-request-controller/src/QueuedRequestController.test.ts index 3f3e1ff631..4839d82654 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.test.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.test.ts @@ -1,7 +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, @@ -11,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', () => { @@ -46,7 +39,7 @@ describe('QueuedRequestController', () => { }; const controller = new QueuedRequestController(options); - await controller.enqueueRequest(async () => { + await controller.enqueueRequest(buildRequest(), async () => { expect(controller.state.queuedRequestCount).toBe(1); }); expect(controller.state.queuedRequestCount).toBe(0); @@ -60,11 +53,15 @@ describe('QueuedRequestController', () => { const { promise: firstRequestProcessing, resolve: resolveFirstRequest } = createDeferredPromise(); const firstRequest = controller.enqueueRequest( + buildRequest(), () => firstRequestProcessing, ); - const secondRequest = controller.enqueueRequest(async () => { - expect(controller.state.queuedRequestCount).toBe(1); - }); + const secondRequest = controller.enqueueRequest( + buildRequest(), + async () => { + expect(controller.state.queuedRequestCount).toBe(1); + }, + ); expect(controller.state.queuedRequestCount).toBe(2); @@ -83,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(), @@ -101,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'); @@ -137,11 +216,77 @@ describe('QueuedRequestController', () => { // Enqueue the request await expect(() => - controller.enqueueRequest(requestWithError), + controller.enqueueRequest(buildRequest(), requestWithError), ).rejects.toThrow(new Error('Request failed')); 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(), @@ -149,7 +294,7 @@ describe('QueuedRequestController', () => { const controller = new QueuedRequestController(options); await expect(() => - controller.enqueueRequest(async () => { + controller.enqueueRequest(buildRequest(), async () => { throw new Error('Request failed'); }), ).rejects.toThrow('Request failed'); @@ -165,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 () => { @@ -177,14 +322,17 @@ 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(); @@ -192,3 +340,122 @@ describe('QueuedRequestController', () => { }); }); }); + +/** + * 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, + }; +} + +/** + * 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 8b3e446337..e2085bbff4 100644 --- a/packages/queued-request-controller/src/QueuedRequestController.ts +++ b/packages/queued-request-controller/src/QueuedRequestController.ts @@ -1,9 +1,18 @@ +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'; @@ -43,11 +52,17 @@ export type QueuedRequestControllerActions = | QueuedRequestControllerGetStateAction | QueuedRequestControllerEnqueueRequestAction; +export type AllowedActions = + | NetworkControllerGetStateAction + | NetworkControllerSetActiveNetworkAction + | NetworkControllerGetNetworkConfigurationByNetworkClientId + | AddApprovalRequest; + export type QueuedRequestControllerMessenger = RestrictedControllerMessenger< typeof controllerName, - QueuedRequestControllerActions, + QueuedRequestControllerActions | AllowedActions, QueuedRequestControllerEvents, - never, + AllowedActions['type'], never >; @@ -101,6 +116,62 @@ export class QueuedRequestController extends BaseController< ); } + /** + * Switch the current globally selected network if necessary for processing the given + * request. + * + * @param request - The request currently being processed. + * @throws Throws an error if the current selected `networkClientId` or the + * `networkClientId` on the request are invalid. + */ + 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.update((state) => { state.queuedRequestCount += change; @@ -112,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.state.queuedRequestCount > 1) { - await this.currentRequest; + 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 169880f07c..0461d5694d 100644 --- a/packages/queued-request-controller/src/index.ts +++ b/packages/queued-request-controller/src/index.ts @@ -13,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; +}; From 969dfd50ada69ea710c6dcf01fefd6370fe22330 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Tue, 27 Feb 2024 17:18:09 +0100 Subject: [PATCH 37/39] feat: remove updateIdentities and make syncIdentities private in preferences controller (#3976) ## Explanation After completion of https://github.com/MetaMask/core/issues/3794 and https://github.com/MetaMask/core/issues/3699, the method `syncIdentities` is now only used internally on `KeyringController:stateChange` event and `updateIdentities` is no longer being used. ## References * Fixes #3795 ## Changelog ### `@metamask@preferences-controller` - **REMOVED**: `syncIdentities` is now private as it's only used internally to update state on KeyringController:stateChange event. - **REMOVED**: `updateIdentities` has been removed, as it's not in use anymore. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../src/PreferencesController.test.ts | 158 ++++++------------ .../src/PreferencesController.ts | 42 +---- 2 files changed, 49 insertions(+), 151 deletions(-) diff --git a/packages/preferences-controller/src/PreferencesController.test.ts b/packages/preferences-controller/src/PreferencesController.test.ts index 81f8442db6..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,117 +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 throw error when syncing identities with empty array', () => { - const controller = setupPreferencesController(); - expect(() => { - controller.syncIdentities([]); - }).toThrow('Expected non-empty array of addresses'); - }); - - 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/'); @@ -443,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 dce4a683f1..b7e53e5d69 100644 --- a/packages/preferences-controller/src/PreferencesController.ts +++ b/packages/preferences-controller/src/PreferencesController.ts @@ -241,7 +241,7 @@ export class PreferencesController extends BaseController< } } if (accounts.size > 0) { - this.syncIdentities(Array.from(accounts)); + this.#syncIdentities(Array.from(accounts)); } }, ); @@ -321,14 +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[]) { - if (!addresses.length) { - throw new Error('Expected non-empty array of addresses'); - } - + #syncIdentities(addresses: string[]) { addresses = addresses.map((address: string) => toChecksumHexAddress(address), ); @@ -355,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]; - } - }); } /** From f7a0c1327dc44e4714182a41fe2ea74beda168f1 Mon Sep 17 00:00:00 2001 From: Zachary Belford Date: Tue, 27 Feb 2024 13:40:17 -0800 Subject: [PATCH 38/39] Listen to permissions changes and add/remove `domains` (#3969) See here for more info: https://app.zenhub.com/workspaces/wallet-api-platform-63bee08a4e3b9d001108416e/issues/gh/metamask/metamask-planning/2142 ## Explanation When there exists a permission for a domain, we will then start saving their network selection. We also retroactively add network selections for domains which already have permissions. ## References ## Changelog ### `@metamask/selected-network-controller` - **CHANGED**: Domain selection is written/deleted when permissions are added/removed ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: Alex Donesky --- .../selected-network-controller/package.json | 1 + .../src/SelectedNetworkController.ts | 56 +++++++++++-- .../tests/SelectedNetworkController.test.ts | 83 ++++++++++++++++++- .../tsconfig.build.json | 3 +- .../selected-network-controller/tsconfig.json | 3 + yarn.lock | 41 ++++----- 6 files changed, 158 insertions(+), 29 deletions(-) diff --git a/packages/selected-network-controller/package.json b/packages/selected-network-controller/package.json index dbe3cc81af..0f7d979b44 100644 --- a/packages/selected-network-controller/package.json +++ b/packages/selected-network-controller/package.json @@ -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 20cccbbbd2..9093ae4633 100644 --- a/packages/selected-network-controller/src/SelectedNetworkController.ts +++ b/packages/selected-network-controller/src/SelectedNetworkController.ts @@ -8,6 +8,11 @@ import type { 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'; @@ -69,11 +74,6 @@ export type SelectedNetworkControllerSetNetworkClientIdForDomainAction = { handler: SelectedNetworkController['setNetworkClientIdForDomain']; }; -type PermissionControllerHasPermissions = { - type: `PermissionController:hasPermissions`; - handler: (domain: string) => boolean; -}; - export type SelectedNetworkControllerActions = | SelectedNetworkControllerGetSelectedNetworkStateAction | SelectedNetworkControllerGetNetworkClientIdForDomainAction @@ -82,12 +82,15 @@ export type SelectedNetworkControllerActions = export type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetStateAction - | PermissionControllerHasPermissions; + | PermissionControllerHasPermissions + | PermissionControllerGetSubjectsAction; export type SelectedNetworkControllerEvents = SelectedNetworkControllerStateChangeEvent; -export type AllowedEvents = NetworkControllerStateChangeEvent; +export type AllowedEvents = + | NetworkControllerStateChangeEvent + | PermissionControllerStateChange; export type SelectedNetworkControllerMessenger = RestrictedControllerMessenger< typeof controllerName, @@ -136,6 +139,45 @@ export class SelectedNetworkController extends BaseController< }); 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) => { diff --git a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts index 9a202639e1..a452945634 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts @@ -32,6 +32,7 @@ function buildMessenger() { * @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({ @@ -40,12 +41,14 @@ export function buildSelectedNetworkControllerMessenger({ SelectedNetworkControllerEvents | AllowedEvents >(), hasPermissions, + getSubjectNames, }: { messenger?: ControllerMessenger< SelectedNetworkControllerActions | AllowedActions, SelectedNetworkControllerEvents | AllowedEvents >; hasPermissions?: boolean; + getSubjectNames?: string[]; } = {}): SelectedNetworkControllerMessenger { messenger.registerActionHandler( 'NetworkController:getNetworkClientById', @@ -62,14 +65,22 @@ export function buildSelectedNetworkControllerMessenger({ 'PermissionController:hasPermissions', jest.fn().mockReturnValue(hasPermissions), ); + messenger.registerActionHandler( + 'PermissionController:getSubjectNames', + jest.fn().mockReturnValue(getSubjectNames), + ); return messenger.getRestricted({ name: controllerName, allowedActions: [ 'NetworkController:getNetworkClientById', 'NetworkController:getState', 'PermissionController:hasPermissions', + 'PermissionController:getSubjectNames', + ], + allowedEvents: [ + 'NetworkController:stateChange', + 'PermissionController:stateChange', ], - allowedEvents: ['NetworkController:stateChange'], }); } @@ -77,10 +88,12 @@ jest.mock('@metamask/swappable-obj-proxy'); const setup = ({ hasPermissions = true, + getSubjectNames = [], state, }: { hasPermissions?: boolean; state?: SelectedNetworkControllerState; + getSubjectNames?: string[]; } = {}) => { const mockProviderProxy = { setTarget: jest.fn(), @@ -121,6 +134,7 @@ const setup = ({ buildSelectedNetworkControllerMessenger({ messenger, hasPermissions, + getSubjectNames, }); const controller = new SelectedNetworkController({ messenger: selectedNetworkControllerMessenger, @@ -432,4 +446,71 @@ describe('SelectedNetworkController', () => { }); }); }); + 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...'] }], + }; + + 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: { perDomainNetwork: true, 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: { perDomainNetwork: true, domains: {} }, + getSubjectNames: getSubjectNamesMock, + }); + + // 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: { + perDomainNetwork: true, + 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/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/yarn.lock b/yarn.lock index a6d71e8b27..74711dc673 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2417,26 +2417,7 @@ __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: @@ -2463,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" @@ -2673,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 From 17c52d99dc8585e3a4f9ed5b06c486d726a83b93 Mon Sep 17 00:00:00 2001 From: Zachary Belford Date: Tue, 27 Feb 2024 15:17:56 -0800 Subject: [PATCH 39/39] remove redundant perDomainNetwork state (#3989) ## Explanation See [here](https://app.zenhub.com/workspaces/wallet-api-platform-63bee08a4e3b9d001108416e/issues/gh/metamask/metamask-planning/2143) for more details ## References ## Changelog ### `@metamask/selected-network-controller` - **ADDED**: SelectedNetworkController constructor now takes a `getUseRequestQueue` function. - **REMOVED**: SelectedNetworkController no longer has a perDappNetwork feature flag saved in state. ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../src/SelectedNetworkController.ts | 33 +++----- .../tests/SelectedNetworkController.test.ts | 76 ++++++------------- 2 files changed, 37 insertions(+), 72 deletions(-) diff --git a/packages/selected-network-controller/src/SelectedNetworkController.ts b/packages/selected-network-controller/src/SelectedNetworkController.ts index 9093ae4633..99f1b33d16 100644 --- a/packages/selected-network-controller/src/SelectedNetworkController.ts +++ b/packages/selected-network-controller/src/SelectedNetworkController.ts @@ -20,13 +20,9 @@ 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; @@ -46,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 = { @@ -100,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 = { @@ -120,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, @@ -137,6 +134,7 @@ 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) @@ -267,7 +265,7 @@ export class SelectedNetworkController extends BaseController< getNetworkClientIdForDomain(domain: Domain): NetworkClientId { const { selectedNetworkClientId: metamaskSelectedNetworkClientId } = this.messagingSystem.call('NetworkController:getState'); - if (!this.state.perDomainNetwork) { + if (!this.#getUseRequestQueue()) { return metamaskSelectedNetworkClientId; } return this.state.domains[domain] ?? metamaskSelectedNetworkClientId; @@ -280,9 +278,9 @@ export class SelectedNetworkController extends BaseController< * @returns The proxy and block tracker proxies. */ getProviderAndBlockTracker(domain: Domain): NetworkProxy { - if (!this.state.perDomainNetwork) { + if (!this.#getUseRequestQueue()) { throw new Error( - 'Provider and BlockTracker should be fetched from NetworkController when perDomainNetwork is false', + 'Provider and BlockTracker should be fetched from NetworkController when useRequestQueue is false', ); } const networkClientId = this.state.domains[domain]; @@ -308,11 +306,4 @@ export class SelectedNetworkController extends BaseController< return networkProxy; } - - setPerDomainNetwork(enabled: boolean) { - this.update((state) => { - state.perDomainNetwork = enabled; - return state; - }); - } } diff --git a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts index a452945634..d4dfcd897a 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts @@ -4,6 +4,7 @@ import { createEventEmitterProxy } from '@metamask/swappable-obj-proxy'; import type { AllowedActions, AllowedEvents, + GetUseRequestQueue, SelectedNetworkControllerActions, SelectedNetworkControllerEvents, SelectedNetworkControllerMessenger, @@ -90,10 +91,12 @@ const setup = ({ hasPermissions = true, getSubjectNames = [], state, + getUseRequestQueue = () => false, }: { hasPermissions?: boolean; state?: SelectedNetworkControllerState; getSubjectNames?: string[]; + getUseRequestQueue?: GetUseRequestQueue; } = {}) => { const mockProviderProxy = { setTarget: jest.fn(), @@ -139,6 +142,7 @@ const setup = ({ const controller = new SelectedNetworkController({ messenger: selectedNetworkControllerMessenger, state, + getUseRequestQueue, }); return { controller, @@ -158,19 +162,16 @@ describe('SelectedNetworkController', () => { const { controller } = setup(); expect(controller.state).toStrictEqual({ domains: {}, - perDomainNetwork: false, }); }); it('can be instantiated with a state', () => { const { controller } = setup({ state: { - perDomainNetwork: true, domains: { networkClientId: 'goerli' }, }, }); expect(controller.state).toStrictEqual({ domains: { networkClientId: 'goerli' }, - perDomainNetwork: true, }); }); }); @@ -180,7 +181,6 @@ describe('SelectedNetworkController', () => { it('updates the networkClientId for domains which were previously set to the deleted networkClientId', () => { const { controller, messenger } = setup({ state: { - perDomainNetwork: true, domains: { metamask: 'goerli', 'example.com': 'test-network-client-id', @@ -223,12 +223,11 @@ describe('SelectedNetworkController', () => { ); expect(controller.state.domains.metamask).toBeUndefined(); }); - describe('when the perDomainNetwork state is false', () => { + 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: { - perDomainNetwork: false, domains: { '1.com': 'mainnet', '2.com': 'mainnet', @@ -250,12 +249,13 @@ describe('SelectedNetworkController', () => { }); }); - describe('when the perDomainNetwork state is true', () => { + 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: { perDomainNetwork: true, domains: {} }, + state: { domains: {} }, hasPermissions: true, + getUseRequestQueue: () => true, }); const domain = 'example.com'; @@ -266,8 +266,9 @@ describe('SelectedNetworkController', () => { it('updates the provider and block tracker proxy when they already exist for the domain', () => { const { controller, mockProviderProxy } = setup({ - state: { perDomainNetwork: true, domains: {} }, + state: { domains: {} }, hasPermissions: true, + getUseRequestQueue: () => true, }); const initialNetworkClientId = '123'; @@ -294,7 +295,7 @@ describe('SelectedNetworkController', () => { 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: { perDomainNetwork: true, domains: {} }, + state: { domains: {} }, hasPermissions: false, }); @@ -312,7 +313,7 @@ describe('SelectedNetworkController', () => { }); describe('getNetworkClientIdForDomain', () => { - describe('when the perDomainNetwork state is false', () => { + 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( @@ -334,11 +335,12 @@ describe('SelectedNetworkController', () => { }); }); - describe('when the perDomainNetwork state is true', () => { + 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: { perDomainNetwork: true, domains: {} }, + state: { domains: {} }, hasPermissions: true, + getUseRequestQueue: () => true, }); const networkClientId1 = 'network5'; const networkClientId2 = 'network6'; @@ -352,8 +354,9 @@ describe('SelectedNetworkController', () => { it('returns the selectedNetworkClientId from the NetworkController when no networkClientId has been set for the domain requested', () => { const { controller } = setup({ - state: { perDomainNetwork: true, domains: {} }, + state: { domains: {} }, hasPermissions: true, + getUseRequestQueue: () => true, }); expect(controller.getNetworkClientIdForDomain('example.com')).toBe( 'mainnet', @@ -363,13 +366,13 @@ describe('SelectedNetworkController', () => { }); describe('getProviderAndBlockTracker', () => { - describe('when perDomainNetwork is true', () => { + 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: { - perDomainNetwork: true, domains: {}, }, + getUseRequestQueue: () => true, }); controller.setNetworkClientIdForDomain('example.com', 'network7'); const result = controller.getProviderAndBlockTracker('example.com'); @@ -379,11 +382,11 @@ describe('SelectedNetworkController', () => { it('creates a new proxy provider and block tracker when there isnt one already', () => { const { controller } = setup({ state: { - perDomainNetwork: true, domains: { 'test.com': 'mainnet', }, }, + getUseRequestQueue: () => true, }); const result = controller.getProviderAndBlockTracker('test.com'); expect(result).toBeDefined(); @@ -392,9 +395,9 @@ describe('SelectedNetworkController', () => { it('throws and error when a networkClientId has not been set for the requested domain', () => { const { controller } = setup({ state: { - perDomainNetwork: true, domains: {}, }, + getUseRequestQueue: () => true, }); expect(() => { @@ -402,11 +405,10 @@ describe('SelectedNetworkController', () => { }).toThrow('NetworkClientId has not been set for the requested domain'); }); }); - describe('when perDomainNetwork is false', () => { + describe('when useRequestQueue is false', () => { it('throws and error when a networkClientId has been been set for the requested domain', () => { const { controller } = setup({ state: { - perDomainNetwork: false, domains: {}, }, }); @@ -414,38 +416,11 @@ describe('SelectedNetworkController', () => { expect(() => { controller.getProviderAndBlockTracker('test.com'); }).toThrow( - 'Provider and BlockTracker should be fetched from NetworkController when perDomainNetwork is false', + 'Provider and BlockTracker should be fetched from NetworkController when useRequestQueue is false', ); }); }); }); - - describe('setPerDomainNetwork', () => { - describe('when toggling from false to true', () => { - it('should update perDomainNetwork state to true', () => { - const { controller } = setup({ - state: { - perDomainNetwork: false, - domains: {}, - }, - }); - controller.setPerDomainNetwork(true); - expect(controller.state.perDomainNetwork).toBe(true); - }); - }); - describe('when toggling from true to false', () => { - it('should update perDomainNetwork state to false', () => { - const { controller } = setup({ - state: { - perDomainNetwork: true, - domains: {}, - }, - }); - controller.setPerDomainNetwork(false); - expect(controller.state.perDomainNetwork).toBe(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(); @@ -470,7 +445,7 @@ describe('SelectedNetworkController', () => { it('should remove domain from domains list on permission removal', async () => { const { controller, messenger } = setup({ - state: { perDomainNetwork: true, domains: { 'example.com': 'foo' } }, + state: { domains: { 'example.com': 'foo' } }, }); messenger.publish('PermissionController:stateChange', { subjects: {} }, [ @@ -488,7 +463,7 @@ describe('SelectedNetworkController', () => { it('should set networkClientId for domains not already in state', async () => { const getSubjectNamesMock = ['newdomain.com']; const { controller } = setup({ - state: { perDomainNetwork: true, domains: {} }, + state: { domains: {} }, getSubjectNames: getSubjectNamesMock, }); @@ -499,7 +474,6 @@ describe('SelectedNetworkController', () => { it('should not modify domains already in state', async () => { const { controller } = setup({ state: { - perDomainNetwork: true, domains: { 'existingdomain.com': 'initialNetworkId', },