diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js index 3679c698b0a..eeeef9619a2 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 48cd497dffb..553e20ed2b8 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 ce7f030a394..2df7c260442 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 ec80c8a8ac7..f7eeb0105ad 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 00000000000..bffbb78e4a1 --- /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 617d6765f9b..d70c394bd83 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 bf320bc8ee9..5274d6c9b4d 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 274f1128eed..49b39c4effc 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 331b6861454..bd8b66aeaf4 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 00000000000..133258eb6cb --- /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 00000000000..3af5c2c09a0 --- /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 71bfb86be4d..ff7c244ddc1 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 ae48ba4c48c..c23bfc3e8ca 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 c2610741d0a..c805f776d91 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 3087c729f64..222dbc1240b 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 ffcaec1dacc..cec423cc93a 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 b550470488e..2eb04c1c952 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 23c7fe06705..53e66f73e81 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 961578c4f93..4fc306e218f 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 e48cdd46c57..87238f3a69b 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 346a5b400a0..545f3a8156b 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 00000000000..6598f9b9bc1 --- /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 00000000000..101009fce52 --- /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 9581cc64489..b4b5b90fd1a 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 b03168c257d..614fb1e1be1 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