From 980f28c4caf6147ccb0d67499af18827c4db80df Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 5 Jun 2026 07:35:54 +0100 Subject: [PATCH 1/8] refactor(transaction-controller): remove incoming transaction support and defer initialization --- packages/transaction-controller/CHANGELOG.md | 16 + ...ansactionController-method-action-types.ts | 30 - .../src/TransactionController.test.ts | 134 -- .../src/TransactionController.ts | 165 +- .../TransactionControllerIntegration.test.ts | 4 +- ...AccountsApiRemoteTransactionSource.test.ts | 214 --- .../AccountsApiRemoteTransactionSource.ts | 230 --- .../helpers/IncomingTransactionHelper.test.ts | 1370 ----------------- .../src/helpers/IncomingTransactionHelper.ts | 499 ------ .../helpers/MultichainTrackingHelper.test.ts | 6 +- .../src/helpers/MultichainTrackingHelper.ts | 7 + packages/transaction-controller/src/index.ts | 5 - packages/transaction-controller/src/types.ts | 1 - .../src/utils/feature-flags.test.ts | 76 +- .../src/utils/feature-flags.ts | 76 +- 15 files changed, 65 insertions(+), 2768 deletions(-) delete mode 100644 packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.test.ts delete mode 100644 packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts delete mode 100644 packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts delete mode 100644 packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 65568276ad..ffa41d708e 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Add public `initialize()` method to `TransactionController`; must be called by the consumer after all dependent controllers are registered on the messenger ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) + - `MultichainTrackingHelper` no longer initializes synchronously in its constructor; initialization is deferred until `TransactionController.initialize()` is called, so the controller can be constructed before `NetworkController` is available. + +### Removed + +- **BREAKING:** Remove incoming transaction support from `TransactionController` ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) + - Deleted `IncomingTransactionHelper` and `AccountsApiRemoteTransactionSource`. + - Removed constructor option `incomingTransactions`. + - Removed public methods `startIncomingTransactionPolling`, `stopIncomingTransactionPolling`, `updateIncomingTransactions`. + - Removed event `TransactionController:incomingTransactionsReceived`. + - Removed exported constant `INCOMING_TRANSACTIONS_SUPPORTED_CHAIN_IDS`. + - Removed exported types `TransactionControllerIncomingTransactionsReceivedEvent`, `TransactionControllerStartIncomingTransactionPollingAction`, `TransactionControllerStopIncomingTransactionPollingAction`, `TransactionControllerUpdateIncomingTransactionsAction`. + - Fields on `TransactionMeta` related to incoming transactions are preserved. + ### Changed - **BREAKING:** Remove deprecated `TransactionController` constructor options and unused hooks, and replace them with direct messenger calls ([#8983](https://github.com/MetaMask/core/pull/8983)) diff --git a/packages/transaction-controller/src/TransactionController-method-action-types.ts b/packages/transaction-controller/src/TransactionController-method-action-types.ts index 3647ece7de..8090c91ffb 100644 --- a/packages/transaction-controller/src/TransactionController-method-action-types.ts +++ b/packages/transaction-controller/src/TransactionController-method-action-types.ts @@ -53,33 +53,6 @@ export type TransactionControllerAddTransactionAction = { handler: TransactionController['addTransaction']; }; -/** - * Starts polling for incoming transactions from the remote transaction source. - */ -export type TransactionControllerStartIncomingTransactionPollingAction = { - type: `TransactionController:startIncomingTransactionPolling`; - handler: TransactionController['startIncomingTransactionPolling']; -}; - -/** - * Stops polling for incoming transactions from the remote transaction source. - */ -export type TransactionControllerStopIncomingTransactionPollingAction = { - type: `TransactionController:stopIncomingTransactionPolling`; - handler: TransactionController['stopIncomingTransactionPolling']; -}; - -/** - * Update the incoming transactions by polling the remote transaction source. - * - * @param request - Request object. - * @param request.tags - Additional tags to identify the source of the request. - */ -export type TransactionControllerUpdateIncomingTransactionsAction = { - type: `TransactionController:updateIncomingTransactions`; - handler: TransactionController['updateIncomingTransactions']; -}; - /** * Attempts to cancel a transaction based on its ID by setting its status to "rejected" * and emitting a `:finished` hub event. @@ -443,9 +416,6 @@ export type TransactionControllerMethodActions = | TransactionControllerAddTransactionBatchAction | TransactionControllerIsAtomicBatchSupportedAction | TransactionControllerAddTransactionAction - | TransactionControllerStartIncomingTransactionPollingAction - | TransactionControllerStopIncomingTransactionPollingAction - | TransactionControllerUpdateIncomingTransactionsAction | TransactionControllerStopTransactionAction | TransactionControllerSpeedUpTransactionAction | TransactionControllerEstimateGasAction diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 45780679e2..9b55f8c3a9 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -59,7 +59,6 @@ import { updateTransactionGasEstimates, GasFeePoller, } from './helpers/GasFeePoller'; -import { IncomingTransactionHelper } from './helpers/IncomingTransactionHelper'; import { MethodDataHelper } from './helpers/MethodDataHelper'; import { MultichainTrackingHelper } from './helpers/MultichainTrackingHelper'; import { PendingTransactionTracker } from './helpers/PendingTransactionTracker'; @@ -151,7 +150,6 @@ jest.mock('./gas-flows/RandomisedEstimationsGasFeeFlow'); jest.mock('./gas-flows/LineaGasFeeFlow'); jest.mock('./gas-flows/TestGasFeeFlow'); jest.mock('./helpers/GasFeePoller'); -jest.mock('./helpers/IncomingTransactionHelper'); jest.mock('./helpers/MethodDataHelper'); jest.mock('./helpers/MultichainTrackingHelper'); jest.mock('./helpers/PendingTransactionTracker'); @@ -457,7 +455,6 @@ describe('TransactionController', () => { ); let getNonceLockSpy: jest.Mock; - let incomingTransactionHelperMock: jest.Mocked; let pendingTransactionTrackerMock: jest.Mocked; let multichainTrackingHelperMock: jest.Mocked; let defaultGasFeeFlowMock: jest.Mocked; @@ -470,11 +467,6 @@ describe('TransactionController', () => { let signMock: jest.Mock; let isEIP7702GasFeeTokensEnabledMock: jest.Mock; - const incomingTransactionHelperClassMock = - IncomingTransactionHelper as jest.MockedClass< - typeof IncomingTransactionHelper - >; - const pendingTransactionTrackerClassMock = PendingTransactionTracker as jest.MockedClass< typeof PendingTransactionTracker @@ -842,19 +834,6 @@ describe('TransactionController', () => { releaseLock: () => Promise.resolve(), }); - 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; - }); - pendingTransactionTrackerMock = { start: jest.fn(), stop: jest.fn(), @@ -4865,119 +4844,6 @@ describe('TransactionController', () => { }); }); - describe('on incoming transaction helper transactions event', () => { - it('adds new transactions to state', async () => { - const { controller } = setupController(); - - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (incomingTransactionHelperMock.hub.on as any).mock.calls[0][1]([ - TRANSACTION_META_MOCK, - TRANSACTION_META_2_MOCK, - ]); - - expect(controller.state.transactions).toStrictEqual([ - { - ...TRANSACTION_META_MOCK, - networkClientId: InfuraNetworkType.sepolia, - }, - { - ...TRANSACTION_META_2_MOCK, - networkClientId: InfuraNetworkType.sepolia, - }, - ]); - }); - - it('limits max transactions when adding to state', async () => { - getTransactionHistoryLimitMock.mockReturnValue(1); - - const { controller } = setupController(); - - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (incomingTransactionHelperMock.hub.on as any).mock.calls[0][1]([ - TRANSACTION_META_MOCK, - TRANSACTION_META_2_MOCK, - ]); - - expect(controller.state.transactions).toStrictEqual([ - { - ...TRANSACTION_META_2_MOCK, - networkClientId: InfuraNetworkType.sepolia, - }, - ]); - }); - - it('publishes TransactionController:incomingTransactionsReceived', async () => { - const listener = jest.fn(); - - const { messenger } = setupController(); - messenger.subscribe( - 'TransactionController:incomingTransactionsReceived', - listener, - ); - - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (incomingTransactionHelperMock.hub.on as any).mock.calls[0][1]([ - TRANSACTION_META_MOCK, - TRANSACTION_META_2_MOCK, - ]); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith([ - { - ...TRANSACTION_META_MOCK, - networkClientId: InfuraNetworkType.sepolia, - }, - { - ...TRANSACTION_META_2_MOCK, - networkClientId: InfuraNetworkType.sepolia, - }, - ]); - }); - - it('does not publish TransactionController:incomingTransactionsReceived if no new transactions', async () => { - const listener = jest.fn(); - - const { messenger } = setupController(); - - messenger.subscribe( - 'TransactionController:incomingTransactionsReceived', - listener, - ); - - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (incomingTransactionHelperMock.hub.on as any).mock.calls[0][1]([]); - - expect(listener).toHaveBeenCalledTimes(0); - }); - - it('ignores transactions with unrecognised chain ID', async () => { - const { controller } = setupController(); - - const unknownChainTx = { - ...TRANSACTION_META_MOCK, - chainId: '0xdeadbeef' as const, - } as TransactionMeta; - - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (incomingTransactionHelperMock.hub.on as any).mock.calls[0][1]([ - unknownChainTx, - TRANSACTION_META_2_MOCK, - ]); - - expect(controller.state.transactions).toStrictEqual([ - { - ...TRANSACTION_META_2_MOCK, - networkClientId: InfuraNetworkType.sepolia, - }, - ]); - }); - }); - describe('updateTransactionGasFees', () => { it('throws if transaction does not exist', async () => { const { controller } = setupController(); diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 78cda55372..2a8c3f3595 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -1,10 +1,8 @@ /* eslint-disable no-restricted-syntax */ import type { TypedTxData } from '@ethereumjs/tx'; import type { - AccountsController, AccountsControllerGetSelectedAccountAction, AccountsControllerGetStateAction, - AccountsControllerSelectedAccountChangeEvent, } from '@metamask/accounts-controller'; import type { AcceptResultCallbacks, @@ -23,11 +21,8 @@ import { convertHexToDecimal, } from '@metamask/controller-utils'; import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; -import type { - AccountActivityServiceStatusChangedEvent, - AccountActivityServiceTransactionUpdatedEvent, - BackendWebSocketServiceConnectionStateChangedEvent, -} from '@metamask/core-backend'; + +import type { AccountActivityServiceTransactionUpdatedEvent } from '@metamask/core-backend'; import type { FetchGasFeeEstimateOptions, GasFeeControllerFetchGasFeeEstimatesAction, @@ -79,14 +74,11 @@ import { OptimismLayer1GasFeeFlow } from './gas-flows/OptimismLayer1GasFeeFlow'; import { RandomisedEstimationsGasFeeFlow } from './gas-flows/RandomisedEstimationsGasFeeFlow'; import { ScrollLayer1GasFeeFlow } from './gas-flows/ScrollLayer1GasFeeFlow'; import { TestGasFeeFlow } from './gas-flows/TestGasFeeFlow'; -import { AccountsApiRemoteTransactionSource } from './helpers/AccountsApiRemoteTransactionSource'; import { GasFeePoller, updateTransactionGasProperties, updateTransactionGasEstimates, } from './helpers/GasFeePoller'; -import type { IncomingTransactionOptions } from './helpers/IncomingTransactionHelper'; -import { IncomingTransactionHelper } from './helpers/IncomingTransactionHelper'; import { MethodDataHelper } from './helpers/MethodDataHelper'; import { MultichainTrackingHelper } from './helpers/MultichainTrackingHelper'; import { PendingTransactionTracker } from './helpers/PendingTransactionTracker'; @@ -133,7 +125,6 @@ import type { AddTransactionOptions, PublishHookResult, GetGasFeeTokensRequest, - InternalAccount, } from './types'; import { GasFeeEstimateLevel, @@ -339,12 +330,6 @@ export type TransactionControllerOptions = { */ getSimulationConfig?: GetSimulationConfig; - /** Configuration options for incoming transaction support. */ - incomingTransactions?: IncomingTransactionOptions & { - /** @deprecated Ignored as Etherscan no longer used. */ - etherscanApiKeysByChainId?: Record; - }; - /** * Callback to determine whether gas fee updates should be enabled for a given transaction. * Returns true to enable updates, false to disable them. @@ -438,10 +423,7 @@ export type AllowedActions = * The external events available to the {@link TransactionController}. */ export type AllowedEvents = - | AccountActivityServiceStatusChangedEvent | AccountActivityServiceTransactionUpdatedEvent - | AccountsControllerSelectedAccountChangeEvent - | BackendWebSocketServiceConnectionStateChangedEvent | NetworkControllerStateChangeEvent; /** @@ -452,14 +434,6 @@ export type TransactionControllerStateChangeEvent = ControllerStateChangeEvent< TransactionControllerState >; -/** - * Represents the `TransactionController:incomingTransactionsReceived` event. - */ -export type TransactionControllerIncomingTransactionsReceivedEvent = { - type: `${typeof controllerName}:incomingTransactionsReceived`; - payload: [incomingTransactions: TransactionMeta[]]; -}; - /** * Represents the `TransactionController:postTransactionBalanceUpdated` event. */ @@ -614,7 +588,6 @@ export type TransactionControllerUnapprovedTransactionAddedEvent = { * The internal events available to the {@link TransactionController}. */ export type TransactionControllerEvents = - | TransactionControllerIncomingTransactionsReceivedEvent | TransactionControllerPostTransactionBalanceUpdatedEvent | TransactionControllerSpeedupTransactionAddedEvent | TransactionControllerStateChangeEvent @@ -688,13 +661,10 @@ const MESSENGER_EXPOSED_METHODS = [ 'isAtomicBatchSupported', 'setTransactionActive', 'speedUpTransaction', - 'startIncomingTransactionPolling', - 'stopIncomingTransactionPolling', 'stopTransaction', 'updateAtomicBatchData', 'updateCustodialTransaction', 'updateEditableParams', - 'updateIncomingTransactions', 'updatePreviousGasParams', 'updateRequiredTransactionIds', 'updateSecurityAlertResponse', @@ -734,12 +704,6 @@ export class TransactionController extends BaseController< readonly #getSimulationConfig: GetSimulationConfig; - readonly #incomingTransactionHelper: IncomingTransactionHelper; - - readonly #incomingTransactionOptions: IncomingTransactionOptions & { - etherscanApiKeysByChainId?: Record; - }; - readonly #internalEvents = new EventEmitter(); readonly #isAutomaticGasFeeUpdateEnabled: ( @@ -793,7 +757,6 @@ export class TransactionController extends BaseController< getSavedGasFees, getSimulationConfig, hooks, - incomingTransactions = {}, isAutomaticGasFeeUpdateEnabled, isEIP7702GasFeeTokensEnabled, isFirstTimeInteractionEnabled, @@ -835,7 +798,6 @@ export class TransactionController extends BaseController< this.#getSimulationConfig = getSimulationConfig ?? ((): ReturnType => Promise.resolve({})); - this.#incomingTransactionOptions = incomingTransactions; this.#isAutomaticGasFeeUpdateEnabled = isAutomaticGasFeeUpdateEnabled ?? ((_txMeta: TransactionMeta): boolean => false); @@ -885,7 +847,6 @@ export class TransactionController extends BaseController< }, }); - this.#multichainTrackingHelper.initialize(); this.#gasFeeFlows = this.#getGasFeeFlows(); this.#layer1GasFeeFlows = this.#getLayer1GasFeeFlows(); @@ -932,25 +893,6 @@ export class TransactionController extends BaseController< }, ); - this.#incomingTransactionHelper = new IncomingTransactionHelper({ - client: this.#incomingTransactionOptions.client, - getCurrentAccount: (): ReturnType< - AccountsController['getSelectedAccount'] - > => this.#getSelectedAccount(), - getLocalTransactions: (): TransactionMeta[] => this.state.transactions, - includeTokenTransfers: - this.#incomingTransactionOptions.includeTokenTransfers, - isEnabled: this.#incomingTransactionOptions.isEnabled, - messenger: this.messenger, - remoteTransactionSource: new AccountsApiRemoteTransactionSource(), - trimTransactions: this.#trimTransactionsForState.bind(this), - updateTransactions: this.#incomingTransactionOptions.updateTransactions, - }); - - this.#addIncomingTransactionHelperListeners( - this.#incomingTransactionHelper, - ); - // when transactionsController state changes // check for pending transactions and start polling if there are any this.messenger.subscribe( @@ -976,6 +918,14 @@ export class TransactionController extends BaseController< this.#registerActionHandlers(); } + /** + * Initializes the controller by setting up network client tracking. + * Must be called after all dependent controllers are registered on the messenger. + */ + initialize(): void { + this.#multichainTrackingHelper.initialize(); + } + /** * Stops polling and removes listeners to prepare the controller for garbage collection. */ @@ -1328,32 +1278,6 @@ export class TransactionController extends BaseController< }; } - /** - * Starts polling for incoming transactions from the remote transaction source. - */ - startIncomingTransactionPolling(): void { - this.#incomingTransactionHelper.start(); - } - - /** - * Stops polling for incoming transactions from the remote transaction source. - */ - stopIncomingTransactionPolling(): void { - this.#incomingTransactionHelper.stop(); - } - - /** - * Update the incoming transactions by polling the remote transaction source. - * - * @param request - Request object. - * @param request.tags - Additional tags to identify the source of the request. - */ - async updateIncomingTransactions({ - tags, - }: { tags?: string[] } = {}): Promise { - await this.#incomingTransactionHelper.update({ tags }); - } - /** * Attempts to cancel a transaction based on its ID by setting its status to "rejected" * and emitting a `:finished` hub event. @@ -3317,9 +3241,14 @@ export class TransactionController extends BaseController< #trimTransactionsForState( transactions: TransactionMeta[], ): TransactionMeta[] { - const nonceNetworkSet = new Set(); const transactionHistoryLimit = getTransactionHistoryLimit(this.messenger); + if (transactionHistoryLimit === undefined) { + return transactions; + } + + const nonceNetworkSet = new Set(); + const txsToKeep = [...transactions] .sort((a, b) => (a.time > b.time ? -1 : 1)) // Descending time order .filter((tx) => { @@ -3448,55 +3377,6 @@ export class TransactionController extends BaseController< return { meta: transaction, isCompleted }; } - #onIncomingTransactions(transactions: TransactionMeta[]): void { - if (!transactions.length) { - return; - } - - const finalTransactions: TransactionMeta[] = []; - - for (const tx of transactions) { - const { chainId } = tx; - - try { - const networkClientId = getNetworkClientId({ - messenger: this.messenger, - chainId, - }); - - finalTransactions.push({ - ...tx, - networkClientId, - }); - } catch (error) { - log('Failed to get network client ID for incoming transaction', { - chainId, - error, - }); - } - } - - this.update((state) => { - const { transactions: currentTransactions } = state; - - state.transactions = this.#trimTransactionsForState([ - ...finalTransactions, - ...currentTransactions, - ]); - - log( - 'Added incoming transactions to state', - finalTransactions.length, - finalTransactions, - ); - }); - - this.messenger.publish( - `${controllerName}:incomingTransactionsReceived`, - finalTransactions, - ); - } - #generateDappSuggestedGasFees( txParams: TransactionParams, origin?: string, @@ -3936,15 +3816,6 @@ export class TransactionController extends BaseController< this.#multichainTrackingHelper.stopAllTracking(); } - #addIncomingTransactionHelperListeners( - incomingTransactionHelper: IncomingTransactionHelper, - ): void { - incomingTransactionHelper.hub.on( - 'transactions', - this.#onIncomingTransactions.bind(this), - ); - } - #removePendingTransactionTrackerListeners( pendingTransactionTracker: PendingTransactionTracker, ): void { @@ -4283,10 +4154,6 @@ export class TransactionController extends BaseController< }); } - #getSelectedAccount(): InternalAccount { - return this.messenger.call('AccountsController:getSelectedAccount'); - } - #getInternalAccounts(): Hex[] { const state = this.messenger.call('AccountsController:getState'); diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index 6c1e5c8106..6516865d48 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -319,13 +319,11 @@ const setupController = async ( getPermittedAccounts: async () => [ACCOUNT_MOCK], hooks: {}, messenger, - pendingTransactions: { - isResubmitEnabled: () => false, - }, ...givenOptions, }; const transactionController = new TransactionController(options); + transactionController.initialize(); return { transactionController, diff --git a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.test.ts b/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.test.ts deleted file mode 100644 index 6abb00da21..0000000000 --- a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import type { - GetAccountTransactionsResponse, - TransactionResponse, -} from '../api/accounts-api'; -import { getAccountTransactions } from '../api/accounts-api'; -import { TransactionType } from '../types'; -import type { RemoteTransactionSourceRequest } from '../types'; -import { determineTransactionType } from '../utils/transaction-type'; -import { - AccountsApiRemoteTransactionSource, - SUPPORTED_CHAIN_IDS, -} from './AccountsApiRemoteTransactionSource'; - -jest.mock('../api/accounts-api'); -jest.mock('../utils/transaction-type'); - -jest.useFakeTimers(); - -const ADDRESS_MOCK = '0x123'; -const ONE_DAY_MS = 1000 * 60 * 60 * 24; -const NOW_MOCK = 789000 + ONE_DAY_MS; - -const REQUEST_MOCK: RemoteTransactionSourceRequest = { - address: ADDRESS_MOCK, - includeTokenTransfers: true, - updateTransactions: true, -}; - -const RESPONSE_STANDARD_MOCK: TransactionResponse = { - hash: '0x1', - timestamp: new Date(123000).toISOString(), - chainId: 1, - blockNumber: 1, - blockHash: '0x2', - gas: 1, - gasUsed: 1, - gasPrice: '1', - effectiveGasPrice: '1', - nonce: 1, - cumulativeGasUsed: 1, - methodId: '0x12345678', - value: '1', - to: ADDRESS_MOCK, - from: '0x2', - isError: false, - valueTransfers: [], -}; - -const RESPONSE_TOKEN_TRANSFER_MOCK: TransactionResponse = { - ...RESPONSE_STANDARD_MOCK, - to: '0x456', - valueTransfers: [ - { - contractAddress: '0x123', - decimal: 18, - symbol: 'ABC', - from: '0x2', - to: ADDRESS_MOCK, - amount: '1', - }, - ], -}; - -const TRANSACTION_STANDARD_MOCK = { - blockNumber: '1', - chainId: '0x1', - error: undefined, - hash: '0x1', - id: expect.any(String), - isTransfer: false, - networkClientId: '', - status: 'confirmed', - time: 123000, - toSmartContract: false, - transferInformation: undefined, - txParams: { - chainId: '0x1', - data: '0x12345678', - from: '0x2', - gas: '0x1', - gasPrice: '0x1', - gasUsed: '0x1', - nonce: '0x1', - to: '0x123', - value: '0x1', - }, - type: TransactionType.incoming, - verifiedOnBlockchain: false, -}; - -const TRANSACTION_TOKEN_TRANSFER_MOCK = { - ...TRANSACTION_STANDARD_MOCK, - isTransfer: true, - transferInformation: { - amount: '1', - contractAddress: '0x123', - decimals: 18, - symbol: 'ABC', - }, -}; - -describe('AccountsApiRemoteTransactionSource', () => { - const getAccountTransactionsMock = jest.mocked(getAccountTransactions); - const determineTransactionTypeMock = jest.mocked(determineTransactionType); - - beforeEach(() => { - jest.resetAllMocks(); - jest.setSystemTime(NOW_MOCK); - - getAccountTransactionsMock.mockResolvedValue( - {} as GetAccountTransactionsResponse, - ); - - determineTransactionTypeMock.mockResolvedValue({ - type: TransactionType.tokenMethodTransfer, - getCodeResponse: undefined, - }); - }); - - describe('getSupportedChains', () => { - it('returns supported chains', () => { - const supportedChains = - new AccountsApiRemoteTransactionSource().getSupportedChains(); - expect(supportedChains.length).toBeGreaterThan(0); - }); - }); - - describe('fetchTransactions', () => { - it('queries accounts API', async () => { - await new AccountsApiRemoteTransactionSource().fetchTransactions( - REQUEST_MOCK, - ); - - expect(getAccountTransactionsMock).toHaveBeenCalledTimes(1); - expect(getAccountTransactionsMock).toHaveBeenCalledWith({ - address: ADDRESS_MOCK, - chainIds: SUPPORTED_CHAIN_IDS, - sortDirection: 'DESC', - }); - }); - - it('returns normalized standard transaction', async () => { - getAccountTransactionsMock.mockResolvedValue({ - data: [RESPONSE_STANDARD_MOCK], - pageInfo: { hasNextPage: false, count: 1 }, - }); - - const transactions = - await new AccountsApiRemoteTransactionSource().fetchTransactions( - REQUEST_MOCK, - ); - - expect(transactions).toStrictEqual([TRANSACTION_STANDARD_MOCK]); - }); - - it('returns normalized token transfer transaction', async () => { - getAccountTransactionsMock.mockResolvedValue({ - data: [RESPONSE_TOKEN_TRANSFER_MOCK], - pageInfo: { hasNextPage: false, count: 1 }, - }); - - const transactions = - await new AccountsApiRemoteTransactionSource().fetchTransactions( - REQUEST_MOCK, - ); - - expect(transactions).toStrictEqual([TRANSACTION_TOKEN_TRANSFER_MOCK]); - }); - - it('ignores outgoing transactions if updateTransactions is false', async () => { - getAccountTransactionsMock.mockResolvedValue({ - data: [{ ...RESPONSE_STANDARD_MOCK, to: '0x456' }], - pageInfo: { hasNextPage: false, count: 1 }, - }); - - const transactions = - await new AccountsApiRemoteTransactionSource().fetchTransactions({ - ...REQUEST_MOCK, - updateTransactions: false, - }); - - expect(transactions).toStrictEqual([]); - }); - - it('ignores incoming token transfers if includeTokenTransfers is false', async () => { - getAccountTransactionsMock.mockResolvedValue({ - data: [RESPONSE_TOKEN_TRANSFER_MOCK], - pageInfo: { hasNextPage: false, count: 1 }, - }); - - const transactions = - await new AccountsApiRemoteTransactionSource().fetchTransactions({ - ...REQUEST_MOCK, - includeTokenTransfers: false, - }); - - expect(transactions).toStrictEqual([]); - }); - - it('determines transaction type if outgoing', async () => { - getAccountTransactionsMock.mockResolvedValue({ - data: [{ ...RESPONSE_TOKEN_TRANSFER_MOCK, from: ADDRESS_MOCK }], - pageInfo: { hasNextPage: false, count: 1 }, - }); - - const transactions = - await new AccountsApiRemoteTransactionSource().fetchTransactions( - REQUEST_MOCK, - ); - - expect(transactions[0].type).toBe(TransactionType.tokenMethodTransfer); - }); - }); -}); diff --git a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts b/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts deleted file mode 100644 index ae1b35167e..0000000000 --- a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { BNToHex } from '@metamask/controller-utils'; -import type { Hex } from '@metamask/utils'; -import BN from 'bn.js'; -import { v1 as random } from 'uuid'; - -import type { - GetAccountTransactionsResponse, - TransactionResponse, -} from '../api/accounts-api'; -import { getAccountTransactions } from '../api/accounts-api'; -import { CHAIN_IDS } from '../constants'; -import { createModuleLogger, incomingTransactionsLogger } from '../logger'; -import type { - RemoteTransactionSource, - RemoteTransactionSourceRequest, - TransactionError, - TransactionMeta, -} from '../types'; -import { TransactionStatus, TransactionType } from '../types'; -import { determineTransactionType } from '../utils/transaction-type'; - -export const SUPPORTED_CHAIN_IDS: Hex[] = [ - CHAIN_IDS.MAINNET, - CHAIN_IDS.POLYGON, - CHAIN_IDS.BSC, - CHAIN_IDS.LINEA_MAINNET, - CHAIN_IDS.BASE, - CHAIN_IDS.OPTIMISM, - CHAIN_IDS.ARBITRUM, - CHAIN_IDS.SCROLL, - CHAIN_IDS.SEI, - CHAIN_IDS.MONAD, - CHAIN_IDS.HYPEREVM, -]; - -const log = createModuleLogger( - incomingTransactionsLogger, - 'accounts-api-source', -); - -/** - * A RemoteTransactionSource that fetches incoming transactions using the Accounts API. - */ -export class AccountsApiRemoteTransactionSource implements RemoteTransactionSource { - getSupportedChains(): Hex[] { - return SUPPORTED_CHAIN_IDS; - } - - async fetchTransactions( - request: RemoteTransactionSourceRequest, - ): Promise { - const { address, chainIds } = request; - - const responseTransactions = await this.#queryTransactions( - request, - chainIds ?? SUPPORTED_CHAIN_IDS, - ); - - log( - 'Fetched transactions', - responseTransactions.length, - responseTransactions, - ); - - const normalizedTransactions = await Promise.all( - responseTransactions.map((tx) => this.#normalizeTransaction(address, tx)), - ); - - log('Normalized transactions', normalizedTransactions); - - const filteredTransactions = this.#filterTransactions( - request, - normalizedTransactions, - ); - - log( - 'Filtered transactions', - filteredTransactions.length, - filteredTransactions, - ); - - return filteredTransactions; - } - - async #queryTransactions( - request: RemoteTransactionSourceRequest, - chainIds: Hex[], - ): Promise { - const { address, tags } = request; - const transactions: TransactionResponse[] = []; - - try { - const response = await getAccountTransactions({ - address, - chainIds, - sortDirection: 'DESC', - tags, - }); - - if (response?.data) { - transactions.push(...response.data); - } - } catch (error) { - log('Error while fetching transactions', error); - } - - return transactions; - } - - #filterTransactions( - request: RemoteTransactionSourceRequest, - transactions: TransactionMeta[], - ): TransactionMeta[] { - const { address, includeTokenTransfers, updateTransactions } = request; - - let filteredTransactions = transactions; - - if (!updateTransactions) { - filteredTransactions = filteredTransactions.filter( - (tx) => tx.txParams.to === address, - ); - } - - if (!includeTokenTransfers) { - filteredTransactions = filteredTransactions.filter( - (tx) => !tx.isTransfer, - ); - } - - return filteredTransactions; - } - - async #normalizeTransaction( - address: Hex, - responseTransaction: GetAccountTransactionsResponse['data'][0], - ): Promise { - const blockNumber = String(responseTransaction.blockNumber); - const chainId = `0x${responseTransaction.chainId.toString(16)}` as const; - const { hash } = responseTransaction; - const time = new Date(responseTransaction.timestamp).getTime(); - const id = random({ msecs: time }); - const { from } = responseTransaction; - const gas = BNToHex(new BN(responseTransaction.gas)); - const gasPrice = BNToHex(new BN(responseTransaction.gasPrice)); - const gasUsed = BNToHex(new BN(responseTransaction.gasUsed)); - const nonce = BNToHex(new BN(responseTransaction.nonce)); - const data = responseTransaction.methodId; - const type = TransactionType.incoming; - const verifiedOnBlockchain = false; - - const status = responseTransaction.isError - ? TransactionStatus.failed - : TransactionStatus.confirmed; - - const valueTransfer = responseTransaction.valueTransfers.find( - (vt) => - (vt.to.toLowerCase() === address.toLowerCase() || - vt.from.toLowerCase() === address.toLowerCase()) && - vt.contractAddress, - ); - - const isIncomingTokenTransfer = - valueTransfer?.to.toLowerCase() === address.toLowerCase() && - from.toLowerCase() !== address.toLowerCase(); - - const isOutgoing = from.toLowerCase() === address.toLowerCase(); - const amount = valueTransfer?.amount; - const contractAddress = valueTransfer?.contractAddress as string; - const decimals = valueTransfer?.decimal as number; - const symbol = valueTransfer?.symbol as string; - - const value = BNToHex( - new BN( - isIncomingTokenTransfer - ? (valueTransfer?.amount ?? responseTransaction.value) - : responseTransaction.value, - ), - ); - - const to = isIncomingTokenTransfer ? address : responseTransaction.to; - - const error = - status === TransactionStatus.failed - ? new Error('Transaction failed') - : (undefined as unknown as TransactionError); - - const transferInformation = valueTransfer - ? { - amount, - contractAddress, - decimals, - symbol, - } - : undefined; - - const meta: TransactionMeta = { - blockNumber, - chainId, - error, - hash, - id, - isTransfer: isIncomingTokenTransfer, - // Populated by TransactionController when added to state - networkClientId: '', - status, - time, - toSmartContract: false, - transferInformation, - txParams: { - chainId, - data, - from, - gas, - gasPrice, - gasUsed, - nonce, - to, - value, - }, - type, - verifiedOnBlockchain, - }; - - if (isOutgoing) { - meta.type = (await determineTransactionType(meta.txParams)).type; - } - - return meta; - } -} diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts deleted file mode 100644 index 38388dcaf1..0000000000 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ /dev/null @@ -1,1370 +0,0 @@ -import type { - Transaction as AccountActivityTransaction, - WebSocketConnectionInfo, -} from '@metamask/core-backend'; -import type { Hex } from '@metamask/utils'; - -import type { TransactionControllerMessenger } from '..'; -import { flushPromises } from '../../../../tests/helpers'; -import { TransactionStatus, TransactionType } from '../types'; -import type { RemoteTransactionSource, TransactionMeta } from '../types'; -import { - getIncomingTransactionsPollingInterval, - isIncomingTransactionsUseBackendWebSocketServiceEnabled, -} from '../utils/feature-flags'; -import { SUPPORTED_CHAIN_IDS } from './AccountsApiRemoteTransactionSource'; -import { - IncomingTransactionHelper, - WebSocketState, -} from './IncomingTransactionHelper'; - -jest.useFakeTimers(); - -jest.mock('../utils/feature-flags'); - -// eslint-disable-next-line jest/prefer-spy-on -console.error = jest.fn(); - -const ADDRESS_MOCK = '0x1'; -const SYSTEM_TIME_MOCK = 1000 * 60 * 60 * 24 * 2; -const MESSENGER_MOCK = { - subscribe: jest.fn(), - unsubscribe: jest.fn(), -} as unknown as TransactionControllerMessenger; -const TAG_MOCK = 'test1'; -const TAG_2_MOCK = 'test2'; -const CLIENT_MOCK = 'test-client'; - -const CONTROLLER_ARGS_MOCK: ConstructorParameters< - typeof IncomingTransactionHelper ->[0] = { - getCurrentAccount: () => { - return { - id: '58def058-d35f-49a1-a7ab-e2580565f6f5', - address: ADDRESS_MOCK, - type: 'eip155:eoa' as const, - options: {}, - methods: [], - scopes: ['eip155:0'], - metadata: { - name: 'Account 1', - keyring: { type: 'HD Key Tree' }, - importTime: 1631619180000, - lastSelected: 1631619180000, - }, - }; - }, - getLocalTransactions: () => [], - messenger: MESSENGER_MOCK, - remoteTransactionSource: {} as RemoteTransactionSource, - trimTransactions: (transactions) => transactions, -}; - -const TRANSACTION_MOCK: TransactionMeta = { - id: '1', - chainId: '0x1', - hash: '0x1', - status: TransactionStatus.submitted, - time: 0, - txParams: { from: '0x2', to: '0x1', gasUsed: '0x1' }, -} as unknown as TransactionMeta; - -const TRANSACTION_MOCK_2: TransactionMeta = { - id: '2', - hash: '0x2', - chainId: '0x1', - time: 1, - txParams: { from: '0x3', to: '0x1' }, -} as unknown as TransactionMeta; - -const createRemoteTransactionSourceMock = ( - remoteTransactions: TransactionMeta[], - { - chainIds, - error, - }: { - chainIds?: Hex[]; - error?: boolean; - } = {}, -): RemoteTransactionSource => ({ - getSupportedChains: jest.fn(() => chainIds ?? SUPPORTED_CHAIN_IDS), - fetchTransactions: jest.fn(() => - error - ? Promise.reject(new Error('Test Error')) - : Promise.resolve(remoteTransactions), - ), -}); - -/** - * Emulate running the interval. - * - * @param helper - The instance of IncomingTransactionHelper to use. - * @param options - The options. - * @param options.start - Whether to start the helper. - * @param options.error - Whether to simulate an error in the incoming-transactions listener. - * @returns The event data and listeners. - */ -async function runInterval( - helper: IncomingTransactionHelper, - { start, error }: { start?: boolean; error?: boolean } = {}, -): Promise<{ - transactions: TransactionMeta[]; - incomingTransactionsListener: jest.Mock; -}> { - const incomingTransactionsListener = jest.fn(); - - if (error) { - incomingTransactionsListener.mockImplementation(() => { - throw new Error('Test Error'); - }); - } - - helper.hub.addListener('transactions', incomingTransactionsListener); - - if (start !== false) { - helper.start(); - } - - jest.runOnlyPendingTimers(); - - await flushPromises(); - - return { - transactions: incomingTransactionsListener.mock.calls[0]?.[0], - incomingTransactionsListener, - }; -} - -const MAINNET_CAIP2 = 'eip155:1'; -const POLYGON_CAIP2 = 'eip155:137'; - -// Helper to convert hex chain ID to CAIP-2 format -const hexToCaip2 = (hexChainId: string): string => { - const decimal = parseInt(hexChainId, 16); - return `eip155:${decimal}`; -}; - -// Convert all supported hex chain IDs to CAIP-2 format for testing -const SUPPORTED_CAIP2_CHAINS = SUPPORTED_CHAIN_IDS.map(hexToCaip2); - -let statusChangedHandler: (event: { - chainIds: string[]; - status: 'up' | 'down'; -}) => void; - -function createMessengerMockWithStatusChanged(): TransactionControllerMessenger { - const localSubscribeMock = jest.fn().mockImplementation((event, handler) => { - if (event === 'AccountActivityService:statusChanged') { - statusChangedHandler = handler; - } - }); - - return { - subscribe: localSubscribeMock, - unsubscribe: jest.fn(), - } as unknown as TransactionControllerMessenger; -} - -describe('IncomingTransactionHelper', () => { - let subscribeMock: jest.Mock; - let unsubscribeMock: jest.Mock; - let transactionUpdatedHandler: (tx: AccountActivityTransaction) => void; - let connectionStateChangedHandler: (info: WebSocketConnectionInfo) => void; - let selectedAccountChangedHandler: () => void; - - beforeEach(() => { - jest.resetAllMocks(); - jest.clearAllTimers(); - jest.setSystemTime(SYSTEM_TIME_MOCK); - - jest - .mocked(getIncomingTransactionsPollingInterval) - .mockReturnValue(1000 * 30); - - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(false); - - subscribeMock = jest.fn().mockImplementation((event, handler) => { - if (event === 'AccountActivityService:transactionUpdated') { - transactionUpdatedHandler = handler; - } else if (event === 'BackendWebSocketService:connectionStateChanged') { - connectionStateChangedHandler = handler; - } else if (event === 'AccountsController:selectedAccountChange') { - selectedAccountChangedHandler = handler; - } - }); - unsubscribeMock = jest.fn(); - }); - - describe('on interval', () => { - // eslint-disable-next-line jest/expect-expect - it('handles errors', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([ - TRANSACTION_MOCK_2, - ]), - }); - - await runInterval(helper, { error: true }); - }); - - it('fetches remote transactions using remote transaction source', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource, - }); - - await runInterval(helper); - - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( - 1, - ); - - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith({ - address: ADDRESS_MOCK, - includeTokenTransfers: true, - tags: ['automatic-polling'], - updateTransactions: false, - }); - }); - - describe('emits transactions event', () => { - it('if new transaction fetched', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([ - TRANSACTION_MOCK_2, - ]), - }); - - const { transactions } = await runInterval(helper); - - expect(transactions).toStrictEqual([TRANSACTION_MOCK_2]); - }); - - it('sorted by time in ascending order', async () => { - const firstTransaction = { ...TRANSACTION_MOCK, time: 5 }; - const secondTransaction = { ...TRANSACTION_MOCK, time: 6 }; - const thirdTransaction = { ...TRANSACTION_MOCK, time: 7 }; - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([ - firstTransaction, - thirdTransaction, - secondTransaction, - ]), - }); - - const { transactions } = await runInterval(helper); - - expect(transactions).toStrictEqual([ - firstTransaction, - secondTransaction, - thirdTransaction, - ]); - }); - - it('excluding duplicates already in local transactions', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - getLocalTransactions: (): TransactionMeta[] => [TRANSACTION_MOCK], - remoteTransactionSource: createRemoteTransactionSourceMock([ - TRANSACTION_MOCK, - TRANSACTION_MOCK_2, - ]), - }); - - const { transactions } = await runInterval(helper); - - expect(transactions).toStrictEqual([TRANSACTION_MOCK_2]); - }); - - it('including transactions with existing hash but unique from', async () => { - const localTransaction = { - ...TRANSACTION_MOCK, - txParams: { from: '0x4' }, - }; - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - getLocalTransactions: (): TransactionMeta[] => [localTransaction], - remoteTransactionSource: createRemoteTransactionSourceMock([ - TRANSACTION_MOCK, - TRANSACTION_MOCK_2, - ]), - }); - - const { transactions } = await runInterval(helper); - - expect(transactions).toStrictEqual([ - TRANSACTION_MOCK, - TRANSACTION_MOCK_2, - localTransaction, - ]); - }); - - it('does not if disabled', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([ - TRANSACTION_MOCK, - ]), - isEnabled: jest - .fn() - .mockReturnValueOnce(true) - .mockReturnValueOnce(false), - }); - - const { incomingTransactionsListener } = await runInterval(helper); - - expect(incomingTransactionsListener).not.toHaveBeenCalled(); - }); - - it('does not if no remote transactions', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - const { incomingTransactionsListener } = await runInterval(helper); - - expect(incomingTransactionsListener).not.toHaveBeenCalled(); - }); - - it('does not if error fetching transactions', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock( - [TRANSACTION_MOCK], - { error: true }, - ), - }); - - const { incomingTransactionsListener } = await runInterval(helper); - - expect(incomingTransactionsListener).not.toHaveBeenCalled(); - }); - - it('does not if not started', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([ - TRANSACTION_MOCK, - ]), - }); - - const { incomingTransactionsListener } = await runInterval(helper, { - start: false, - }); - - expect(incomingTransactionsListener).not.toHaveBeenCalled(); - }); - - it('does not if no unique transactions', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - getLocalTransactions: (): TransactionMeta[] => [TRANSACTION_MOCK], - remoteTransactionSource: createRemoteTransactionSourceMock([ - TRANSACTION_MOCK, - ]), - }); - - const { incomingTransactionsListener } = await runInterval(helper, { - start: false, - }); - - expect(incomingTransactionsListener).not.toHaveBeenCalled(); - }); - - it('does not if all unique transactions are truncated', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - trimTransactions: (): TransactionMeta[] => [], - remoteTransactionSource: createRemoteTransactionSourceMock([ - TRANSACTION_MOCK, - ]), - }); - - const { incomingTransactionsListener } = await runInterval(helper, { - start: false, - }); - - expect(incomingTransactionsListener).not.toHaveBeenCalled(); - }); - }); - }); - - describe('start', () => { - it('adds timeout', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - helper.start(); - - await flushPromises(); - - expect(jest.getTimerCount()).toBe(1); - }); - - it('does nothing if already started', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - helper.start(); - - await flushPromises(); - - helper.start(); - - expect(jest.getTimerCount()).toBe(1); - }); - - it('does nothing if disabled', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - isEnabled: (): boolean => false, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - helper.start(); - - expect(jest.getTimerCount()).toBe(0); - }); - - it('does not queue additional updates if first is still running', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource, - }); - - helper.start(); - helper.stop(); - - helper.start(); - helper.stop(); - - helper.start(); - - await flushPromises(); - - expect(jest.getTimerCount()).toBe(1); - - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( - 1, - ); - }); - }); - - describe('stop', () => { - it('removes timeout', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - helper.start(); - helper.stop(); - - expect(jest.getTimerCount()).toBe(0); - }); - }); - - describe('update', () => { - it('emits transactions event', async () => { - const listener = jest.fn(); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([ - TRANSACTION_MOCK_2, - ]), - }); - - helper.hub.on('transactions', listener); - - await helper.update(); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith([TRANSACTION_MOCK_2]); - }); - - it('including transactions with same hash but different types', async () => { - const localTransaction = { - ...TRANSACTION_MOCK, - type: TransactionType.simpleSend, - }; - - const remoteTransaction = { - ...TRANSACTION_MOCK, - type: TransactionType.incoming, - }; - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - getLocalTransactions: (): TransactionMeta[] => [localTransaction], - remoteTransactionSource: createRemoteTransactionSourceMock([ - remoteTransaction, - ]), - }); - - const listener = jest.fn(); - helper.hub.on('transactions', listener); - await helper.update(); - - expect(listener).toHaveBeenCalledWith([ - remoteTransaction, - localTransaction, - ]); - }); - - it('excluding transactions with same hash and type', async () => { - const localTransaction = { - ...TRANSACTION_MOCK, - type: TransactionType.simpleSend, - }; - - const remoteTransaction = { - ...TRANSACTION_MOCK, - type: TransactionType.simpleSend, - }; - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - getLocalTransactions: (): TransactionMeta[] => [localTransaction], - remoteTransactionSource: createRemoteTransactionSourceMock([ - remoteTransaction, - ]), - }); - - const listener = jest.fn(); - helper.hub.on('transactions', listener); - await helper.update(); - - expect(listener).not.toHaveBeenCalled(); - }); - - it('includes correct tags in remote transaction source request', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - client: CLIENT_MOCK, - remoteTransactionSource, - }); - - await helper.update({ isInterval: false, tags: [TAG_MOCK, TAG_2_MOCK] }); - - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith( - expect.objectContaining({ - tags: [CLIENT_MOCK, TAG_MOCK, TAG_2_MOCK], - }), - ); - }); - }); - - describe('transaction history retrieval when useBackendWebSocketService is enabled', () => { - beforeEach(() => { - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(true); - }); - - function createMessengerMock(): TransactionControllerMessenger { - return { - subscribe: subscribeMock, - unsubscribe: unsubscribeMock, - } as unknown as TransactionControllerMessenger; - } - - function createConnectionInfo( - state: WebSocketState, - ): WebSocketConnectionInfo { - return { - state, - url: 'wss://test.com', - reconnectAttempts: 0, - timeout: 10000, - reconnectDelay: 10000, - maxReconnectDelay: 60000, - requestTimeout: 30000, - }; - } - - describe('constructor', () => { - it('subscribes to connectionStateChanged when useBackendWebSocketService is enabled', async () => { - const messenger = createMessengerMock(); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - expect(subscribeMock).toHaveBeenCalledWith( - 'BackendWebSocketService:connectionStateChanged', - expect.any(Function), - ); - }); - - it('does not subscribe to connectionStateChanged when useBackendWebSocketService is disabled', async () => { - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(false); - const messenger = createMessengerMock(); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - expect(subscribeMock).not.toHaveBeenCalledWith( - 'BackendWebSocketService:connectionStateChanged', - expect.any(Function), - ); - }); - }); - - describe('start', () => { - it('does not start polling when useBackendWebSocketService is enabled', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - helper.start(); - await flushPromises(); - - expect(jest.getTimerCount()).toBe(0); - }); - }); - - describe('on WebSocket connected', () => { - it('starts transaction history retrieval when WebSocket connects', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource, - }); - - await flushPromises(); - - jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( - 1, - ); - expect(subscribeMock).toHaveBeenCalledWith( - 'AccountActivityService:transactionUpdated', - expect.any(Function), - ); - }); - - it('subscribes to selectedAccountChange when WebSocket connects', async () => { - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - expect(subscribeMock).toHaveBeenCalledWith( - 'AccountsController:selectedAccountChange', - expect.any(Function), - ); - }); - - it('triggers update on selectedAccountChange event after WebSocket connects', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource, - }); - - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); - - selectedAccountChangedHandler(); - - await flushPromises(); - - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( - 1, - ); - }); - - it('triggers update on transactionUpdated event after WebSocket connects', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource, - }); - - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); - - transactionUpdatedHandler({ - id: 'tx-123', - chain: 'eip155:1', - status: 'confirmed', - timestamp: Date.now(), - from: '0xother', - to: ADDRESS_MOCK, - }); - - await flushPromises(); - - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( - 1, - ); - }); - - it('does not start transaction history retrieval if disabled', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - isEnabled: (): boolean => false, - messenger: createMessengerMock(), - remoteTransactionSource, - }); - - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - expect( - remoteTransactionSource.fetchTransactions, - ).not.toHaveBeenCalled(); - expect(subscribeMock).not.toHaveBeenCalledWith( - 'AccountActivityService:transactionUpdated', - expect.any(Function), - ); - expect(subscribeMock).not.toHaveBeenCalledWith( - 'AccountsController:selectedAccountChange', - expect.any(Function), - ); - }); - }); - - describe('on WebSocket disconnected', () => { - it('unsubscribes from transactionUpdated when WebSocket disconnects', async () => { - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.DISCONNECTED), - ); - - expect(unsubscribeMock).toHaveBeenCalledWith( - 'AccountActivityService:transactionUpdated', - expect.any(Function), - ); - }); - - it('unsubscribes from selectedAccountChange when WebSocket disconnects', async () => { - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.DISCONNECTED), - ); - - expect(unsubscribeMock).toHaveBeenCalledWith( - 'AccountsController:selectedAccountChange', - expect.any(Function), - ); - }); - - it('does not throw when WebSocket disconnects before ever connecting', async () => { - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - expect(() => { - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.DISCONNECTED), - ); - }).not.toThrow(); - - expect(unsubscribeMock).not.toHaveBeenCalled(); - }); - - it('does not throw when WebSocket disconnects twice', async () => { - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.DISCONNECTED), - ); - - expect(() => { - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.DISCONNECTED), - ); - }).not.toThrow(); - }); - }); - - describe('error handling', () => { - it('handles error in during transaction history retrieval initial update when getCurrentAccount throws', async () => { - let callCount = 0; - const getCurrentAccountMock = jest.fn().mockImplementation(() => { - callCount += 1; - if (callCount === 1) { - throw new Error('Account error'); - } - return CONTROLLER_ARGS_MOCK.getCurrentAccount(); - }); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - getCurrentAccount: getCurrentAccountMock, - messenger: createMessengerMock(), - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - expect(subscribeMock).toHaveBeenCalledWith( - 'AccountActivityService:transactionUpdated', - expect.any(Function), - ); - }); - - it('handles error in update after transaction event when getCurrentAccount throws', async () => { - let callCount = 0; - const getCurrentAccountMock = jest.fn().mockImplementation(() => { - callCount += 1; - if (callCount === 2) { - throw new Error('Account error'); - } - return CONTROLLER_ARGS_MOCK.getCurrentAccount(); - }); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - getCurrentAccount: getCurrentAccountMock, - messenger: createMessengerMock(), - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - transactionUpdatedHandler({ - id: 'tx-123', - chain: 'eip155:1', - status: 'confirmed', - timestamp: Date.now(), - from: '0xother', - to: ADDRESS_MOCK, - }); - - await flushPromises(); - - expect(getCurrentAccountMock).toHaveBeenCalledTimes(2); - }); - - it('handles error in update after account change event when getCurrentAccount throws', async () => { - let callCount = 0; - const getCurrentAccountMock = jest.fn().mockImplementation(() => { - callCount += 1; - if (callCount === 2) { - throw new Error('Account error'); - } - return CONTROLLER_ARGS_MOCK.getCurrentAccount(); - }); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - getCurrentAccount: getCurrentAccountMock, - messenger: createMessengerMock(), - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - connectionStateChangedHandler( - createConnectionInfo(WebSocketState.CONNECTED), - ); - await flushPromises(); - - selectedAccountChangedHandler(); - - await flushPromises(); - - expect(getCurrentAccountMock).toHaveBeenCalledTimes(2); - }); - }); - }); - - describe('legacy polling mode', () => { - it('uses polling when useBackendWebSocketService is disabled', async () => { - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(false); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - helper.start(); - await flushPromises(); - - expect(jest.getTimerCount()).toBe(1); - }); - - it('clears timeout on stop when polling is active', async () => { - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(false); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - helper.start(); - await flushPromises(); - - jest.advanceTimersByTime(30000); - await flushPromises(); - - expect(jest.getTimerCount()).toBe(1); - - helper.stop(); - - expect(jest.getTimerCount()).toBe(0); - }); - - it('handles error in initial polling gracefully', async () => { - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(false); - - const remoteTransactionSource = createRemoteTransactionSourceMock([], { - error: true, - }); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource, - }); - - helper.start(); - await flushPromises(); - - expect(helper).toBeDefined(); - }); - - it('handles error in initial polling when getCurrentAccount throws', async () => { - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(false); - - const getCurrentAccountMock = jest.fn().mockImplementation(() => { - throw new Error('Account error'); - }); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - getCurrentAccount: getCurrentAccountMock, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - helper.start(); - await flushPromises(); - - expect(helper).toBeDefined(); - }); - - // eslint-disable-next-line jest/expect-expect - it('handles error in polling interval gracefully', async () => { - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(false); - - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource, - }); - - helper.start(); - await flushPromises(); - - jest - .mocked(remoteTransactionSource.fetchTransactions) - .mockRejectedValueOnce(new Error('Test Error')); - - jest.advanceTimersByTime(30000); - await flushPromises(); - }); - - it('reschedules timeout after interval completes', async () => { - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(false); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - helper.start(); - await flushPromises(); - - expect(jest.getTimerCount()).toBe(1); - - jest.advanceTimersByTime(30000); - await flushPromises(); - - expect(jest.getTimerCount()).toBe(1); - }); - }); - - describe('trimTransactions', () => { - it('does not emit when all unique transactions are truncated', async () => { - const listener = jest.fn(); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - remoteTransactionSource: createRemoteTransactionSourceMock([ - TRANSACTION_MOCK_2, - ]), - trimTransactions: (transactions): TransactionMeta[] => - transactions.filter((tx) => tx.id !== TRANSACTION_MOCK_2.id), - }); - - helper.hub.on('transactions', listener); - - await helper.update(); - - expect(listener).not.toHaveBeenCalled(); - }); - }); - - describe('network fallback mechanism', () => { - describe('when useBackendWebSocketService is true', () => { - beforeEach(() => { - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(true); - }); - - it('starts polling when a supported network goes down', async () => { - const messenger = createMessengerMockWithStatusChanged(); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - expect(jest.getTimerCount()).toBe(0); - - // First, bring all supported networks UP - statusChangedHandler({ - chainIds: SUPPORTED_CAIP2_CHAINS, - status: 'up', - }); - - await flushPromises(); - - // All networks are up, so polling should not be running - expect(jest.getTimerCount()).toBe(0); - - // When one supported network goes down, polling should start - // because not all supported networks are up - statusChangedHandler({ - chainIds: [MAINNET_CAIP2], - status: 'down', - }); - - await flushPromises(); - - expect(jest.getTimerCount()).toBe(1); - }); - - it('continues polling when one network comes up but others are still down', async () => { - const messenger = createMessengerMockWithStatusChanged(); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - // First, bring all supported networks UP - statusChangedHandler({ - chainIds: SUPPORTED_CAIP2_CHAINS, - status: 'up', - }); - - await flushPromises(); - - // Bring down two networks - statusChangedHandler({ - chainIds: [MAINNET_CAIP2, POLYGON_CAIP2], - status: 'down', - }); - - await flushPromises(); - - expect(jest.getTimerCount()).toBe(1); - - // Bring one network back up, but others are still down - statusChangedHandler({ - chainIds: [MAINNET_CAIP2], - status: 'up', - }); - - await flushPromises(); - - // Polling should continue because not all networks are up - expect(jest.getTimerCount()).toBe(1); - }); - - it('stops polling when all supported networks are back up', async () => { - const messenger = createMessengerMockWithStatusChanged(); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - // First, bring all supported networks UP - statusChangedHandler({ - chainIds: SUPPORTED_CAIP2_CHAINS, - status: 'up', - }); - - await flushPromises(); - - // Bring down all supported networks - statusChangedHandler({ - chainIds: SUPPORTED_CAIP2_CHAINS, - status: 'down', - }); - - await flushPromises(); - - expect(jest.getTimerCount()).toBe(1); - - // Bring all supported networks back up - statusChangedHandler({ - chainIds: SUPPORTED_CAIP2_CHAINS, - status: 'up', - }); - - await flushPromises(); - - // Polling should stop because all networks are up - expect(jest.getTimerCount()).toBe(0); - }); - - it('does not start polling again if already polling', async () => { - const messenger = createMessengerMockWithStatusChanged(); - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger, - remoteTransactionSource, - }); - - await flushPromises(); - - // First, bring all supported networks UP - statusChangedHandler({ - chainIds: SUPPORTED_CAIP2_CHAINS, - status: 'up', - }); - - await flushPromises(); - - // First network goes down, polling starts - statusChangedHandler({ - chainIds: [MAINNET_CAIP2], - status: 'down', - }); - - await flushPromises(); - - expect(jest.getTimerCount()).toBe(1); - - // Another network goes down, but polling is already running - statusChangedHandler({ - chainIds: [POLYGON_CAIP2], - status: 'down', - }); - - await flushPromises(); - - // Should still have only 1 timer (no duplicate polling) - expect(jest.getTimerCount()).toBe(1); - }); - - it('does not start polling before first statusChanged event', async () => { - const messenger = createMessengerMockWithStatusChanged(); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - // Polling should not start automatically on initialization - expect(jest.getTimerCount()).toBe(0); - }); - }); - - describe('when useBackendWebSocketService is false', () => { - it('does not subscribe to statusChanged events', async () => { - jest - .mocked(isIncomingTransactionsUseBackendWebSocketServiceEnabled) - .mockReturnValue(false); - const localSubscribeMock = jest.fn(); - const messenger = { - subscribe: localSubscribeMock, - unsubscribe: jest.fn(), - } as unknown as TransactionControllerMessenger; - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger, - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - expect(localSubscribeMock).not.toHaveBeenCalledWith( - 'AccountActivityService:statusChanged', - expect.any(Function), - ); - }); - }); - }); -}); diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts deleted file mode 100644 index c723815dba..0000000000 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ /dev/null @@ -1,499 +0,0 @@ -import type { AccountsController } from '@metamask/accounts-controller'; -import type { - Transaction as AccountActivityTransaction, - WebSocketConnectionInfo, -} from '@metamask/core-backend'; -import type { Hex } from '@metamask/utils'; -// This package purposefully relies on Node's EventEmitter module. -// eslint-disable-next-line import-x/no-nodejs-modules -import EventEmitter from 'events'; - -import type { TransactionControllerMessenger } from '..'; -import { incomingTransactionsLogger as log } from '../logger'; -import type { RemoteTransactionSource, TransactionMeta } from '../types'; -import { - getIncomingTransactionsPollingInterval, - isIncomingTransactionsUseBackendWebSocketServiceEnabled, -} from '../utils/feature-flags'; -import { caip2ToHex } from '../utils/utils'; -import { SUPPORTED_CHAIN_IDS } from './AccountsApiRemoteTransactionSource'; - -export enum WebSocketState { - CONNECTED = 'connected', - DISCONNECTED = 'disconnected', -} - -export type IncomingTransactionOptions = { - /** Name of the client to include in requests. */ - client?: string; - - /** Whether to retrieve incoming token transfers. Defaults to false. */ - includeTokenTransfers?: boolean; - - /** Callback to determine if incoming transaction polling is enabled. */ - isEnabled?: () => boolean; - - /** @deprecated No longer used. */ - queryEntireHistory?: boolean; - - /** Whether to retrieve outgoing transactions. Defaults to false. */ - updateTransactions?: boolean; -}; - -const TAG_POLLING = 'automatic-polling'; - -export class IncomingTransactionHelper { - hub: EventEmitter; - - readonly #client?: string; - - readonly #getCurrentAccount: () => ReturnType< - AccountsController['getSelectedAccount'] - >; - - readonly #getLocalTransactions: () => TransactionMeta[]; - - readonly #includeTokenTransfers?: boolean; - - readonly #isEnabled: () => boolean; - - #isRunning: boolean; - - #isUpdating: boolean; - - readonly #messenger: TransactionControllerMessenger; - - readonly #remoteTransactionSource: RemoteTransactionSource; - - #timeoutId?: unknown; - - readonly #trimTransactions: ( - transactions: TransactionMeta[], - ) => TransactionMeta[]; - - #isTransactionHistoryRetrievalActive = false; - - readonly #updateTransactions?: boolean; - - readonly #useBackendWebSocketService: boolean; - - // Chains that need polling (start with all supported, remove as they come up) - readonly #chainsToPoll: Hex[] = [...SUPPORTED_CHAIN_IDS]; - - readonly #connectionStateChangedHandler = ( - connectionInfo: WebSocketConnectionInfo, - ): void => { - this.#onConnectionStateChanged(connectionInfo); - }; - - readonly #transactionUpdatedHandler = ( - transaction: AccountActivityTransaction, - ): void => { - this.#onTransactionUpdated(transaction); - }; - - readonly #selectedAccountChangedHandler = (): void => { - this.#onSelectedAccountChanged(); - }; - - readonly #statusChangedHandler = ({ - chainIds, - status, - }: { - chainIds: string[]; - status: 'up' | 'down'; - }): void => { - this.#onNetworkStatusChanged(chainIds, status); - }; - - constructor({ - client, - getCurrentAccount, - getLocalTransactions, - includeTokenTransfers, - isEnabled, - messenger, - remoteTransactionSource, - trimTransactions, - updateTransactions, - }: { - client?: string; - getCurrentAccount: () => ReturnType< - AccountsController['getSelectedAccount'] - >; - getLocalTransactions: () => TransactionMeta[]; - includeTokenTransfers?: boolean; - isEnabled?: () => boolean; - messenger: TransactionControllerMessenger; - remoteTransactionSource: RemoteTransactionSource; - trimTransactions: (transactions: TransactionMeta[]) => TransactionMeta[]; - updateTransactions?: boolean; - }) { - this.hub = new EventEmitter(); - - this.#client = client; - this.#getCurrentAccount = getCurrentAccount; - this.#getLocalTransactions = getLocalTransactions; - this.#includeTokenTransfers = includeTokenTransfers; - this.#isEnabled = isEnabled ?? ((): boolean => true); - this.#isRunning = false; - this.#isUpdating = false; - this.#messenger = messenger; - this.#remoteTransactionSource = remoteTransactionSource; - this.#trimTransactions = trimTransactions; - this.#updateTransactions = updateTransactions; - this.#useBackendWebSocketService = - isIncomingTransactionsUseBackendWebSocketServiceEnabled(messenger); - - if (this.#useBackendWebSocketService) { - this.#messenger.subscribe( - 'BackendWebSocketService:connectionStateChanged', - this.#connectionStateChangedHandler, - ); - - this.#messenger.subscribe( - 'AccountActivityService:statusChanged', - this.#statusChangedHandler, - ); - } - } - - start(): void { - // When websockets are disabled, allow normal polling (legacy mode) - if (this.#useBackendWebSocketService) { - return; - } - - this.#startPolling(true); - } - - #startPolling(initialPolling = false): void { - if (this.#isRunning) { - return; - } - - if (!this.#canStart()) { - return; - } - - const interval = this.#getInterval(); - - log('Started polling', { - interval, - }); - - this.#isRunning = true; - - if (this.#isUpdating) { - return; - } - - this.#onInterval().catch((error) => { - log(initialPolling ? 'Initial polling failed' : 'Polling failed', error); - }); - } - - stop(): void { - if (this.#timeoutId) { - clearTimeout(this.#timeoutId as number); - } - - if (!this.#isRunning) { - return; - } - - this.#isRunning = false; - - log('Stopped polling'); - } - - #onConnectionStateChanged(connectionInfo: WebSocketConnectionInfo): void { - if (connectionInfo.state === WebSocketState.CONNECTED) { - log('WebSocket connected, starting enhanced mode'); - this.#startTransactionHistoryRetrieval(); - } else if (connectionInfo.state === WebSocketState.DISCONNECTED) { - log('WebSocket disconnected, stopping enhanced mode'); - this.#stopTransactionHistoryRetrieval(); - } - } - - #startTransactionHistoryRetrieval(): void { - if (!this.#canStart()) { - return; - } - - if (this.#isTransactionHistoryRetrievalActive) { - return; - } - - log('Started transaction history retrieval (event-driven)'); - - this.#isTransactionHistoryRetrievalActive = true; - - this.update().catch((error) => { - log('Initial update in transaction history retrieval failed', error); - }); - - this.#messenger.subscribe( - 'AccountActivityService:transactionUpdated', - this.#transactionUpdatedHandler, - ); - - this.#messenger.subscribe( - 'AccountsController:selectedAccountChange', - this.#selectedAccountChangedHandler, - ); - } - - #stopTransactionHistoryRetrieval(): void { - if (!this.#isTransactionHistoryRetrievalActive) { - return; - } - - log('Stopped transaction history retrieval'); - - this.#isTransactionHistoryRetrievalActive = false; - - this.#messenger.unsubscribe( - 'AccountActivityService:transactionUpdated', - this.#transactionUpdatedHandler, - ); - - this.#messenger.unsubscribe( - 'AccountsController:selectedAccountChange', - this.#selectedAccountChangedHandler, - ); - } - - #onTransactionUpdated(transaction: AccountActivityTransaction): void { - log('Received relevant transaction update, triggering update', { - txId: transaction.id, - chain: transaction.chain, - }); - - this.update().catch((error) => { - log('Update after transaction event failed', error); - }); - } - - #onSelectedAccountChanged(): void { - log('Selected account changed, triggering update'); - - this.update().catch((error) => { - log('Update after account change failed', error); - }); - } - - async #onInterval(): Promise { - this.#isUpdating = true; - - try { - // When websockets enabled, only poll chains that are not confirmed up - const chainIds = this.#useBackendWebSocketService - ? this.#chainsToPoll - : undefined; - await this.update({ chainIds, isInterval: true }); - } catch (error) { - console.error('Error while checking incoming transactions', error); - } - - this.#isUpdating = false; - - if (this.#isRunning) { - if (this.#timeoutId) { - clearTimeout(this.#timeoutId as number); - } - - this.#timeoutId = setTimeout( - // eslint-disable-next-line @typescript-eslint/no-misused-promises - () => this.#onInterval(), - this.#getInterval(), - ); - } - } - - async update({ - chainIds, - isInterval, - tags, - }: { - chainIds?: Hex[]; - isInterval?: boolean; - tags?: string[]; - } = {}): Promise { - const finalTags = this.#getTags(tags, isInterval); - - log('Checking for incoming transactions', { - isInterval: Boolean(isInterval), - tags: finalTags, - }); - - if (!this.#canStart()) { - return; - } - - const account = this.#getCurrentAccount(); - const includeTokenTransfers = this.#includeTokenTransfers ?? true; - const updateTransactions = this.#updateTransactions ?? false; - - let remoteTransactions: TransactionMeta[] = []; - - try { - remoteTransactions = - await this.#remoteTransactionSource.fetchTransactions({ - address: account.address as Hex, - chainIds, - includeTokenTransfers, - tags: finalTags, - updateTransactions, - }); - } catch (error: unknown) { - log('Error while fetching remote transactions', error); - return; - } - - if (!remoteTransactions.length) { - return; - } - - this.#sortTransactionsByTime(remoteTransactions); - - log( - 'Found potential transactions', - remoteTransactions.length, - remoteTransactions, - ); - - const localTransactions = this.#getLocalTransactions(); - - const uniqueTransactions = remoteTransactions.filter( - (tx) => - !localTransactions.some( - (currentTx) => - currentTx.hash?.toLowerCase() === tx.hash?.toLowerCase() && - currentTx.txParams.from?.toLowerCase() === - tx.txParams.from?.toLowerCase() && - currentTx.type === tx.type, - ), - ); - - if (!uniqueTransactions.length) { - log('All transactions are already known'); - return; - } - - log( - 'Found unique transactions', - uniqueTransactions.length, - uniqueTransactions, - ); - - const trimmedTransactions = this.#trimTransactions([ - ...uniqueTransactions, - ...localTransactions, - ]); - - const uniqueTransactionIds = uniqueTransactions.map((tx) => tx.id); - - const newTransactions = trimmedTransactions.filter((tx) => - uniqueTransactionIds.includes(tx.id), - ); - - if (!newTransactions.length) { - log('All unique transactions truncated due to limit'); - return; - } - - log('Adding new transactions', newTransactions.length, newTransactions); - - this.hub.emit('transactions', newTransactions); - } - - #sortTransactionsByTime(transactions: TransactionMeta[]): void { - transactions.sort((a, b) => (a.time < b.time ? -1 : 1)); - } - - #canStart(): boolean { - return this.#isEnabled(); - } - - #getInterval(): number { - return getIncomingTransactionsPollingInterval(this.#messenger); - } - - #getTags( - requestTags: string[] | undefined, - isInterval: boolean | undefined, - ): string[] | undefined { - const tags = []; - - if (this.#client) { - tags.push(this.#client); - } - - if (requestTags?.length) { - tags.push(...requestTags); - } else if (isInterval) { - tags.push(TAG_POLLING); - } - - return tags?.length ? tags : undefined; - } - - #onNetworkStatusChanged(chainIds: string[], status: 'up' | 'down'): void { - if (!this.#useBackendWebSocketService) { - return; - } - - let hasChanges = false; - - for (const caip2ChainId of chainIds) { - const hexChainId = caip2ToHex(caip2ChainId); - - if (!hexChainId || !SUPPORTED_CHAIN_IDS.includes(hexChainId)) { - log('Chain ID not recognized or not supported', { - caip2ChainId, - hexChainId, - }); - continue; - } - - if (status === 'up') { - const index = this.#chainsToPoll.indexOf(hexChainId); - if (index !== -1) { - this.#chainsToPoll.splice(index, 1); - hasChanges = true; - log('Supported network came up, removed from polling list', { - chainId: hexChainId, - }); - } - } else if ( - status === 'down' && - !this.#chainsToPoll.includes(hexChainId) - ) { - this.#chainsToPoll.push(hexChainId); - hasChanges = true; - log('Supported network went down, added to polling list', { - chainId: hexChainId, - }); - } - } - - if (!hasChanges) { - log('No changes to polling list', { - chainsToPoll: this.#chainsToPoll, - }); - return; - } - - if (this.#chainsToPoll.length === 0) { - log('Stopping fallback polling - all networks up'); - this.stop(); - } else { - log('Starting fallback polling - some networks need polling', { - chainsToPoll: this.#chainsToPoll, - }); - this.#startPolling(); - } - } -} diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts index 65f8625f3a..773ea549ec 100644 --- a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts @@ -240,9 +240,10 @@ describe('MultichainTrackingHelper', () => { it('refreshes the tracking map', () => { const { options, helper } = newMultichainTrackingHelper(); + helper.initialize(); options.onNetworkStateChange.mock.calls[0][0]({} as NetworkState, []); - expect(options.getNetworkClientRegistry).toHaveBeenCalledTimes(1); + expect(options.getNetworkClientRegistry).toHaveBeenCalledTimes(2); expect(helper.has('mainnet')).toBe(true); expect(helper.has('goerli')).toBe(true); expect(helper.has('sepolia')).toBe(true); @@ -252,6 +253,7 @@ describe('MultichainTrackingHelper', () => { it('refreshes the tracking map and excludes removed networkClientIds in the patches', () => { const { options, helper } = newMultichainTrackingHelper(); + helper.initialize(); options.onNetworkStateChange.mock.calls[0][0]({} as NetworkState, [ { op: 'remove', @@ -260,7 +262,7 @@ describe('MultichainTrackingHelper', () => { }, ]); - expect(options.getNetworkClientRegistry).toHaveBeenCalledTimes(1); + expect(options.getNetworkClientRegistry).toHaveBeenCalledTimes(2); expect(helper.has('mainnet')).toBe(false); expect(helper.has('goerli')).toBe(true); expect(helper.has('sepolia')).toBe(true); diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts index 87e87c40c8..e05a630f96 100644 --- a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts @@ -68,6 +68,8 @@ export class MultichainTrackingHelper { networkClientId: NetworkClientId; }) => PendingTransactionTracker; + #initialized = false; + readonly #nonceMutexesByChainId = new Map>(); readonly #trackingMap: Map< @@ -97,6 +99,10 @@ export class MultichainTrackingHelper { this.#createPendingTransactionTracker = createPendingTransactionTracker; onNetworkStateChange((_, patches) => { + if (!this.#initialized) { + return; + } + const networkClients = this.#getNetworkClientRegistry(); patches.forEach(({ op, path }) => { @@ -114,6 +120,7 @@ export class MultichainTrackingHelper { const networkClients = this.#getNetworkClientRegistry(); this.#refreshTrackingMap(networkClients); + this.#initialized = true; log('Initialized'); } diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index 3d78cc8a5c..99d511917c 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -4,7 +4,6 @@ export type { TransactionControllerActions, TransactionControllerEvents, TransactionControllerGetStateAction, - TransactionControllerIncomingTransactionsReceivedEvent, TransactionControllerPostTransactionBalanceUpdatedEvent, TransactionControllerSpeedupTransactionAddedEvent, TransactionControllerState, @@ -39,9 +38,6 @@ export type { TransactionControllerUpdateTransactionAction, TransactionControllerHandleMethodDataAction, TransactionControllerIsAtomicBatchSupportedAction, - TransactionControllerStartIncomingTransactionPollingAction, - TransactionControllerStopIncomingTransactionPollingAction, - TransactionControllerUpdateIncomingTransactionsAction, TransactionControllerStopTransactionAction, TransactionControllerSpeedUpTransactionAction, TransactionControllerEstimateGasBufferedAction, @@ -138,7 +134,6 @@ export { normalizeTransactionParams, } from './utils/utils'; export { CHAIN_IDS } from './constants'; -export { SUPPORTED_CHAIN_IDS as INCOMING_TRANSACTIONS_SUPPORTED_CHAIN_IDS } from './helpers/AccountsApiRemoteTransactionSource'; export { HARDFORK } from './utils/prepare'; export { getAccountAddressRelationship } from './api/accounts-api'; export type { diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 48cbcd8d05..c4996acf19 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1227,7 +1227,6 @@ export interface RemoteTransactionSourceRequest { /** * An object capable of fetching transaction data from a remote source. - * Used by the IncomingTransactionHelper to retrieve remote transaction data. */ // This interface was created before this ESLint rule was added. // Convert to a `type` in a future major version. diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts index 4a1964805c..8248338575 100644 --- a/packages/transaction-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-controller/src/utils/feature-flags.test.ts @@ -21,9 +21,7 @@ import { getSubmitHistoryLimit, getTransactionHistoryLimit, FeatureFlag, - getIncomingTransactionsPollingInterval, getTimeoutAttempts, - isIncomingTransactionsUseBackendWebSocketServiceEnabled, } from './feature-flags'; import { isValidSignature } from './signature'; @@ -772,28 +770,6 @@ describe('Feature Flags Utils', () => { }); }); - describe('getIncomingTransactionsPollingInterval', () => { - it('returns default value if no feature flags set', () => { - mockFeatureFlags({}); - - expect(getIncomingTransactionsPollingInterval(controllerMessenger)).toBe( - 1000 * 60 * 4, - ); - }); - - it('returns value from remote feature flag controller', () => { - mockFeatureFlags({ - [FeatureFlag.IncomingTransactions]: { - pollingIntervalMs: 5000, - }, - }); - - expect(getIncomingTransactionsPollingInterval(controllerMessenger)).toBe( - 5000, - ); - }); - }); - describe('getTimeoutAttempts', () => { it('returns undefined if no feature flags set', () => { mockFeatureFlags({}); @@ -868,55 +844,5 @@ describe('Feature Flags Utils', () => { }); }); - describe('isIncomingTransactionsUseBackendWebSocketServiceEnabled', () => { - it('returns true when useBackendWebSocketService is true', () => { - mockFeatureFlags({ - [FeatureFlag.IncomingTransactions]: { - useBackendWebSocketService: true, - }, - }); - - expect( - isIncomingTransactionsUseBackendWebSocketServiceEnabled( - controllerMessenger, - ), - ).toBe(true); - }); - - it('returns false when useBackendWebSocketService is false', () => { - mockFeatureFlags({ - [FeatureFlag.IncomingTransactions]: { - useBackendWebSocketService: false, - }, - }); - - expect( - isIncomingTransactionsUseBackendWebSocketServiceEnabled( - controllerMessenger, - ), - ).toBe(false); - }); - - it('returns false when flag is not present', () => { - mockFeatureFlags({}); - - expect( - isIncomingTransactionsUseBackendWebSocketServiceEnabled( - controllerMessenger, - ), - ).toBe(false); - }); - - it('returns false when useBackendWebSocketService property is not present', () => { - mockFeatureFlags({ - [FeatureFlag.IncomingTransactions]: {}, - }); - - expect( - isIncomingTransactionsUseBackendWebSocketServiceEnabled( - controllerMessenger, - ), - ).toBe(false); - }); - }); }); + diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index ecf83ac9f6..ee65bc0967 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -12,7 +12,6 @@ const DEFAULT_ACCELERATED_POLLING_INTERVAL_MS = 3 * 1000; const DEFAULT_BLOCK_TIME = 12 * 1000; const DEFAULT_GAS_ESTIMATE_FALLBACK_BLOCK_PERCENT = 35; const DEFAULT_GAS_ESTIMATE_BUFFER = 1; -const DEFAULT_INCOMING_TRANSACTIONS_POLLING_INTERVAL_MS = 1000 * 60 * 4; // 4 Minutes const DEFAULT_SUBMIT_HISTORY_LIMIT = 100; const DEFAULT_TRANSACTION_HISTORY_LIMIT = 40; @@ -22,7 +21,6 @@ const DEFAULT_TRANSACTION_HISTORY_LIMIT = 40; export enum FeatureFlag { EIP7702 = 'confirmations_eip_7702', GasBuffer = 'confirmations_gas_buffer', - IncomingTransactions = 'confirmations_incoming_transactions', Transactions = 'confirmations_transactions', } @@ -100,15 +98,6 @@ export type TransactionControllerFeatureFlags = { }; }; - /** Incoming transaction configuration. */ - [FeatureFlag.IncomingTransactions]?: { - /** Interval between requests to accounts API to retrieve incoming transactions. */ - pollingIntervalMs?: number; - - /** Whether to use WebSocket for event-driven transaction updates instead of polling. */ - useBackendWebSocketService?: boolean; - }; - /** Miscellaneous feature flags to support the transaction controller. */ [FeatureFlag.Transactions]?: { /** Maximum number of transactions that can be in an external batch. */ @@ -297,10 +286,15 @@ export function getSubmitHistoryLimit( */ export function getTransactionHistoryLimit( messenger: TransactionControllerMessenger, -): number { +): number | undefined { const featureFlags = getFeatureFlags(messenger); + + if (!featureFlags) { + return undefined; + } + return ( - featureFlags?.[FeatureFlag.Transactions]?.transactionHistoryLimit ?? + featureFlags[FeatureFlag.Transactions]?.transactionHistoryLimit ?? DEFAULT_TRANSACTION_HISTORY_LIMIT ); } @@ -437,23 +431,6 @@ export function getGasEstimateBuffer({ /** * Retrieves the incoming transactions polling interval. - * Defaults to 4 minutes if not set. - * - * @param messenger - The controller messenger instance. - * @returns The incoming transactions polling interval in milliseconds. - */ -export function getIncomingTransactionsPollingInterval( - messenger: TransactionControllerMessenger, -): number { - const featureFlags = getFeatureFlags(messenger); - - return ( - featureFlags?.[FeatureFlag.IncomingTransactions]?.pollingIntervalMs ?? - DEFAULT_INCOMING_TRANSACTIONS_POLLING_INTERVAL_MS - ); -} - -/** * Retrieves the number of attempts to wait before automatically marking a transaction as dropped * if it has no receipt status. * @@ -476,24 +453,6 @@ export function getTimeoutAttempts( ); } -/** - * Checks if WebSocket-based transaction updates are enabled. - * When enabled, incoming transactions are fetched via event-driven updates - * instead of polling. - * - * @param messenger - The controller messenger instance. - * @returns True if WebSocket updates are enabled, false otherwise. - */ -export function isIncomingTransactionsUseBackendWebSocketServiceEnabled( - messenger: TransactionControllerMessenger, -): boolean { - const featureFlags = getFeatureFlags(messenger); - return ( - featureFlags?.[FeatureFlag.IncomingTransactions] - ?.useBackendWebSocketService ?? false - ); -} - /** * Retrieves the relevant feature flags from the remote feature flag controller. * @@ -502,12 +461,17 @@ export function isIncomingTransactionsUseBackendWebSocketServiceEnabled( */ function getFeatureFlags( messenger: TransactionControllerMessenger, -): TransactionControllerFeatureFlags { - const featureFlags = messenger.call( - 'RemoteFeatureFlagController:getState', - ).remoteFeatureFlags; - - log('Retrieved feature flags', featureFlags); - - return featureFlags as TransactionControllerFeatureFlags; +): TransactionControllerFeatureFlags | undefined { + try { + const featureFlags = messenger.call( + 'RemoteFeatureFlagController:getState', + ).remoteFeatureFlags; + + log('Retrieved feature flags', featureFlags); + + return featureFlags as TransactionControllerFeatureFlags; + } catch { + log('RemoteFeatureFlagController not available'); + return undefined; + } } From 432115794286504188ef9011d8d11f6febb9728a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 5 Jun 2026 07:36:31 +0100 Subject: [PATCH 2/8] chore: update changelog with PR number --- packages/transaction-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index ffa41d708e..d2e1962752 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,12 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **BREAKING:** Add public `initialize()` method to `TransactionController`; must be called by the consumer after all dependent controllers are registered on the messenger ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) +- **BREAKING:** Add public `initialize()` method to `TransactionController`; must be called by the consumer after all dependent controllers are registered on the messenger ([#9012](https://github.com/MetaMask/core/pull/9012)) - `MultichainTrackingHelper` no longer initializes synchronously in its constructor; initialization is deferred until `TransactionController.initialize()` is called, so the controller can be constructed before `NetworkController` is available. ### Removed -- **BREAKING:** Remove incoming transaction support from `TransactionController` ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) +- **BREAKING:** Remove incoming transaction support from `TransactionController` ([#9012](https://github.com/MetaMask/core/pull/9012)) - Deleted `IncomingTransactionHelper` and `AccountsApiRemoteTransactionSource`. - Removed constructor option `incomingTransactions`. - Removed public methods `startIncomingTransactionPolling`, `stopIncomingTransactionPolling`, `updateIncomingTransactions`. From 02fb9979c88c774d393855ba27710d10eaa9f385 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 5 Jun 2026 11:35:49 +0100 Subject: [PATCH 3/8] chore: switch MultichainTrackingHelper to polling init; rewrite tests --- .../src/TransactionController.ts | 8 ---- .../TransactionControllerIntegration.test.ts | 3 +- .../helpers/MultichainTrackingHelper.test.ts | 43 ++++++++++--------- .../src/helpers/MultichainTrackingHelper.ts | 31 ++++++++++--- 4 files changed, 49 insertions(+), 36 deletions(-) diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 2a8c3f3595..1b68fa2f68 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -918,14 +918,6 @@ export class TransactionController extends BaseController< this.#registerActionHandlers(); } - /** - * Initializes the controller by setting up network client tracking. - * Must be called after all dependent controllers are registered on the messenger. - */ - initialize(): void { - this.#multichainTrackingHelper.initialize(); - } - /** * Stops polling and removes listeners to prepare the controller for garbage collection. */ diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index 6516865d48..5dfd6c8980 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -323,7 +323,8 @@ const setupController = async ( }; const transactionController = new TransactionController(options); - transactionController.initialize(); + + await jestAdvanceTime({ duration: 10 }); return { transactionController, diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts index 773ea549ec..c52f7c17a4 100644 --- a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts @@ -225,6 +225,8 @@ function newMultichainTrackingHelper( describe('MultichainTrackingHelper', () => { beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + for (const network of [ 'mainnet', 'goerli', @@ -236,11 +238,15 @@ describe('MultichainTrackingHelper', () => { } }); + afterEach(() => { + jest.useRealTimers(); + }); + describe('onNetworkStateChange', () => { - it('refreshes the tracking map', () => { + it('refreshes the tracking map', async () => { const { options, helper } = newMultichainTrackingHelper(); - helper.initialize(); + await jestAdvanceTime({ duration: 10 }); options.onNetworkStateChange.mock.calls[0][0]({} as NetworkState, []); expect(options.getNetworkClientRegistry).toHaveBeenCalledTimes(2); @@ -250,10 +256,10 @@ describe('MultichainTrackingHelper', () => { expect(helper.has('customNetworkClientId-1')).toBe(true); }); - it('refreshes the tracking map and excludes removed networkClientIds in the patches', () => { + it('refreshes the tracking map and excludes removed networkClientIds in the patches', async () => { const { options, helper } = newMultichainTrackingHelper(); - helper.initialize(); + await jestAdvanceTime({ duration: 10 }); options.onNetworkStateChange.mock.calls[0][0]({} as NetworkState, [ { op: 'remove', @@ -270,11 +276,11 @@ describe('MultichainTrackingHelper', () => { }); }); - describe('initialize', () => { - it('initializes the tracking map', () => { + describe('#waitForNetworkController', () => { + it('initializes the tracking map', async () => { const { options, helper } = newMultichainTrackingHelper(); - helper.initialize(); + await jestAdvanceTime({ duration: 10 }); expect(options.getNetworkClientRegistry).toHaveBeenCalledTimes(1); expect(helper.has('mainnet')).toBe(true); @@ -285,10 +291,10 @@ describe('MultichainTrackingHelper', () => { }); describe('stopAllTracking', () => { - it('clears the tracking map', () => { + it('clears the tracking map', async () => { const { helper } = newMultichainTrackingHelper(); - helper.initialize(); + await jestAdvanceTime({ duration: 10 }); expect(helper.has('mainnet')).toBe(true); expect(helper.has('goerli')).toBe(true); @@ -305,7 +311,7 @@ describe('MultichainTrackingHelper', () => { }); describe('#startTrackingByNetworkClientId', () => { - it('instantiates trackers and adds them to the tracking map', () => { + it('instantiates trackers and adds them to the tracking map', async () => { const { options, helper } = newMultichainTrackingHelper({ getNetworkClientRegistry: jest.fn().mockReturnValue({ mainnet: { @@ -316,7 +322,7 @@ describe('MultichainTrackingHelper', () => { }), }); - helper.initialize(); + await jestAdvanceTime({ duration: 10 }); expect(options.createNonceTracker).toHaveBeenCalledTimes(1); expect(options.createNonceTracker).toHaveBeenCalledWith({ @@ -336,7 +342,7 @@ describe('MultichainTrackingHelper', () => { }); describe('#stopTrackingByNetworkClientId', () => { - it('stops trackers and removes them from the tracking map', () => { + it('stops trackers and removes them from the tracking map', async () => { const { options, mockPendingTransactionTrackers, helper } = newMultichainTrackingHelper({ getNetworkClientRegistry: jest.fn().mockReturnValue({ @@ -348,7 +354,7 @@ describe('MultichainTrackingHelper', () => { }), }); - helper.initialize(); + await jestAdvanceTime({ duration: 10 }); expect(helper.has('mainnet')).toBe(true); @@ -371,7 +377,7 @@ describe('MultichainTrackingHelper', () => { .spyOn(helper, 'acquireNonceLockForChainIdKey') .mockResolvedValue(jest.fn()); - helper.initialize(); + await jestAdvanceTime({ duration: 10 }); await helper.getNonceLock('0xdeadbeef', 'mainnet'); @@ -388,7 +394,7 @@ describe('MultichainTrackingHelper', () => { .spyOn(helper, 'acquireNonceLockForChainIdKey') .mockResolvedValue(jest.fn()); - helper.initialize(); + await jestAdvanceTime({ duration: 10 }); await helper.getNonceLock('0xdeadbeef', 'mainnet'); @@ -405,7 +411,7 @@ describe('MultichainTrackingHelper', () => { .spyOn(helper, 'acquireNonceLockForChainIdKey') .mockResolvedValue(releaseLockForChainIdKey); - helper.initialize(); + await jestAdvanceTime({ duration: 10 }); const nonceLock = await helper.getNonceLock('0xdeadbeef', 'mainnet'); @@ -442,7 +448,7 @@ describe('MultichainTrackingHelper', () => { .spyOn(helper, 'acquireNonceLockForChainIdKey') .mockResolvedValue(releaseLockForChainIdKey); - helper.initialize(); + await jestAdvanceTime({ duration: 10 }); mockNonceTrackers['0x1'].getNonceLock.mockRejectedValue( 'failed to acquire lock from nonceTracker', @@ -476,7 +482,6 @@ describe('MultichainTrackingHelper', () => { }); it('should block on attempts to get the lock for the same chainId and key combination', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const { helper } = newMultichainTrackingHelper(); const firstReleaseLockPromise = helper.acquireNonceLockForChainIdKey({ @@ -516,8 +521,6 @@ describe('MultichainTrackingHelper', () => { ]); expect(secondReleaseLockIfAcquired).toStrictEqual(expect.any(Function)); - - jest.useRealTimers(); }); }); diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts index e05a630f96..261e86718c 100644 --- a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.ts @@ -70,6 +70,8 @@ export class MultichainTrackingHelper { #initialized = false; + #initInterval?: ReturnType; + readonly #nonceMutexesByChainId = new Map>(); readonly #trackingMap: Map< @@ -114,15 +116,25 @@ export class MultichainTrackingHelper { this.#refreshTrackingMap(networkClients); }); - } - initialize(): void { - const networkClients = this.#getNetworkClientRegistry(); - - this.#refreshTrackingMap(networkClients); - this.#initialized = true; + this.#waitForNetworkController(); + } - log('Initialized'); + #waitForNetworkController(): void { + log('Waiting for NetworkController to be available'); + + this.#initInterval = setInterval(() => { + try { + const networkClients = this.#getNetworkClientRegistry(); + clearInterval(this.#initInterval); + this.#initInterval = undefined; + this.#refreshTrackingMap(networkClients); + this.#initialized = true; + log('Initialized'); + } catch { + log('NetworkController not yet available, retrying'); + } + }, 10); } has(networkClientId: NetworkClientId): boolean { @@ -214,6 +226,11 @@ export class MultichainTrackingHelper { }; stopAllTracking(): void { + if (this.#initInterval) { + clearInterval(this.#initInterval); + this.#initInterval = undefined; + } + for (const [networkClientId] of this.#trackingMap) { this.#stopTrackingByNetworkClientId(networkClientId); } From 790d6e6d093d6c4d09b1ed675c421de1ed049571 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 5 Jun 2026 11:54:41 +0100 Subject: [PATCH 4/8] refactor: remove unused accounts-api exports and defer MultichainTrackingHelper init via polling --- packages/transaction-controller/CHANGELOG.md | 8 +- .../src/api/accounts-api.test.ts | 68 +---------- .../src/api/accounts-api.ts | 109 ------------------ 3 files changed, 5 insertions(+), 180 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index d2e1962752..aea31f0d34 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,11 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- **BREAKING:** Add public `initialize()` method to `TransactionController`; must be called by the consumer after all dependent controllers are registered on the messenger ([#9012](https://github.com/MetaMask/core/pull/9012)) - - `MultichainTrackingHelper` no longer initializes synchronously in its constructor; initialization is deferred until `TransactionController.initialize()` is called, so the controller can be constructed before `NetworkController` is available. - ### Removed - **BREAKING:** Remove incoming transaction support from `TransactionController` ([#9012](https://github.com/MetaMask/core/pull/9012)) @@ -21,7 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed event `TransactionController:incomingTransactionsReceived`. - Removed exported constant `INCOMING_TRANSACTIONS_SUPPORTED_CHAIN_IDS`. - Removed exported types `TransactionControllerIncomingTransactionsReceivedEvent`, `TransactionControllerStartIncomingTransactionPollingAction`, `TransactionControllerStopIncomingTransactionPollingAction`, `TransactionControllerUpdateIncomingTransactionsAction`. + - Removed unused exports `getAccountTransactions`, `TransactionResponse`, `GetAccountTransactionsRequest`, `GetAccountTransactionsResponse` from the accounts API module. - Fields on `TransactionMeta` related to incoming transactions are preserved. +- **BREAKING:** `MultichainTrackingHelper` no longer initializes synchronously in its constructor ([#9012](https://github.com/MetaMask/core/pull/9012)) + - Initialization is now deferred: the helper polls until `NetworkController` is available on the messenger, allowing `TransactionController` to be constructed before `NetworkController` is registered. ### Changed diff --git a/packages/transaction-controller/src/api/accounts-api.test.ts b/packages/transaction-controller/src/api/accounts-api.test.ts index bb77541f52..defef88908 100644 --- a/packages/transaction-controller/src/api/accounts-api.test.ts +++ b/packages/transaction-controller/src/api/accounts-api.test.ts @@ -1,37 +1,18 @@ import { successfulFetch } from '@metamask/controller-utils'; -import type { Hex } from '@metamask/utils'; import { FirstTimeInteractionError } from '../errors'; -import type { - GetAccountAddressRelationshipRequest, - GetAccountTransactionsResponse, -} from './accounts-api'; -import { - getAccountAddressRelationship, - getAccountTransactions, -} from './accounts-api'; +import type { GetAccountAddressRelationshipRequest } from './accounts-api'; +import { getAccountAddressRelationship } from './accounts-api'; jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), successfulFetch: jest.fn(), })); -const ADDRESS_MOCK = '0x123'; -const CHAIN_IDS_MOCK = ['0x1', '0x2'] as Hex[]; -const CURSOR_MOCK = '0x456'; -const END_TIMESTAMP_MOCK = 123; -const START_TIMESTAMP_MOCK = 456; const CHAIN_ID_SUPPORTED = 1; const CHAIN_ID_UNSUPPORTED = 123456789; const FROM_ADDRESS = '0xSender'; const TO_ADDRESS = '0xRecipient'; -const TAG_MOCK = 'test1'; -const TAG_2_MOCK = 'test2'; - -const ACCOUNT_RESPONSE_MOCK = { - data: [{}], -} as unknown as GetAccountTransactionsResponse; - const FIRST_TIME_REQUEST_MOCK: GetAccountAddressRelationshipRequest = { chainId: CHAIN_ID_SUPPORTED, from: FROM_ADDRESS, @@ -39,8 +20,6 @@ const FIRST_TIME_REQUEST_MOCK: GetAccountAddressRelationshipRequest = { }; describe('Accounts API', () => { - const fetchMock = jest.mocked(successfulFetch); - /** * Mock the fetch function to return the given response JSON. * @@ -128,47 +107,4 @@ describe('Accounts API', () => { }); }); - describe('getAccountTransactions', () => { - it('queries the accounts API with the correct parameters', async () => { - mockFetch(ACCOUNT_RESPONSE_MOCK); - - const response = await getAccountTransactions({ - address: ADDRESS_MOCK, - chainIds: CHAIN_IDS_MOCK, - cursor: CURSOR_MOCK, - endTimestamp: END_TIMESTAMP_MOCK, - startTimestamp: START_TIMESTAMP_MOCK, - }); - - expect(response).toStrictEqual(ACCOUNT_RESPONSE_MOCK); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - `https://accounts.api.cx.metamask.io/v1/accounts/${ADDRESS_MOCK}/transactions?networks=${CHAIN_IDS_MOCK[0]},${CHAIN_IDS_MOCK[1]}&startTimestamp=${START_TIMESTAMP_MOCK}&endTimestamp=${END_TIMESTAMP_MOCK}&cursor=${CURSOR_MOCK}`, - expect.any(Object), - ); - }); - - it('includes the client header', async () => { - mockFetch(ACCOUNT_RESPONSE_MOCK); - - await getAccountTransactions({ - address: ADDRESS_MOCK, - chainIds: CHAIN_IDS_MOCK, - cursor: CURSOR_MOCK, - endTimestamp: END_TIMESTAMP_MOCK, - startTimestamp: START_TIMESTAMP_MOCK, - tags: [TAG_MOCK, TAG_2_MOCK], - }); - - expect(fetchMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: { - 'x-metamask-clientproduct': `metamask-transaction-controller__${TAG_MOCK}__${TAG_2_MOCK}`, - }, - }), - ); - }); - }); }); diff --git a/packages/transaction-controller/src/api/accounts-api.ts b/packages/transaction-controller/src/api/accounts-api.ts index 84fd67cd8b..6a86cd8db8 100644 --- a/packages/transaction-controller/src/api/accounts-api.ts +++ b/packages/transaction-controller/src/api/accounts-api.ts @@ -1,5 +1,4 @@ import { successfulFetch } from '@metamask/controller-utils'; -import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { FirstTimeInteractionError } from '../errors'; @@ -47,54 +46,7 @@ export type GetAccountAddressRelationshipRequest = { from: string; }; -export type TransactionResponse = { - hash: Hex; - timestamp: string; - chainId: number; - blockNumber: number; - blockHash: Hex; - gas: number; - gasUsed: number; - gasPrice: string; - effectiveGasPrice: string; - nonce: number; - cumulativeGasUsed: number; - methodId?: Hex; - value: string; - to: string; - from: string; - isError: boolean; - valueTransfers: { - contractAddress: string; - decimal: number; - symbol: string; - from: string; - to: string; - amount: string; - }[]; -}; - -export type GetAccountTransactionsRequest = { - address: Hex; - chainIds?: Hex[]; - cursor?: string; - endTimestamp?: number; - sortDirection?: 'ASC' | 'DESC'; - startTimestamp?: number; - tags?: string[]; -}; - -export type GetAccountTransactionsResponse = { - data: TransactionResponse[]; - pageInfo: { - count: number; - hasNextPage: boolean; - cursor?: string; - }; -}; - const BASE_URL = `https://accounts.api.cx.metamask.io`; -const BASE_URL_ACCOUNTS = `${BASE_URL}/v1/accounts/`; const CLIENT_HEADER = 'x-metamask-clientproduct'; const CLIENT_ID = 'metamask-transaction-controller'; @@ -158,65 +110,4 @@ export async function getAccountAddressRelationship( return responseJson; } -/** - * Fetch account transactions from the accounts API. - * - * @param request - The request object. - * @returns The response object. - */ -export async function getAccountTransactions( - request: GetAccountTransactionsRequest, -): Promise { - const { - address, - chainIds, - cursor, - endTimestamp, - sortDirection, - startTimestamp, - tags, - } = request; - - let url = `${BASE_URL_ACCOUNTS}${address}/transactions`; - const params = []; - - if (chainIds) { - const network = chainIds.join(','); - params.push(`networks=${network}`); - } - - if (startTimestamp) { - params.push(`startTimestamp=${startTimestamp}`); - } - if (endTimestamp) { - params.push(`endTimestamp=${endTimestamp}`); - } - - if (cursor) { - params.push(`cursor=${cursor}`); - } - - if (sortDirection) { - params.push(`sortDirection=${sortDirection}`); - } - - if (params.length) { - url += `?${params.join('&')}`; - } - - log('Getting account transactions', { request, url }); - - const clientId = [CLIENT_ID, ...(tags ?? [])].join('__'); - - const headers = { - [CLIENT_HEADER]: clientId, - }; - - const response = await successfulFetch(url, { headers }); - const responseJson = await response.json(); - - log('Retrieved account transactions', responseJson); - - return responseJson; -} From 567711427d294e7735ddd8ab84ac7dcfea6339ac Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 5 Jun 2026 11:59:37 +0100 Subject: [PATCH 5/8] chore: fix changelog ordering and formatting --- packages/transaction-controller/CHANGELOG.md | 27 +++++++++---------- .../src/TransactionController.ts | 1 - .../src/api/accounts-api.test.ts | 1 - .../src/api/accounts-api.ts | 2 -- .../src/utils/feature-flags.test.ts | 2 -- 5 files changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index aea31f0d34..d81a954533 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,22 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Removed - -- **BREAKING:** Remove incoming transaction support from `TransactionController` ([#9012](https://github.com/MetaMask/core/pull/9012)) - - Deleted `IncomingTransactionHelper` and `AccountsApiRemoteTransactionSource`. - - Removed constructor option `incomingTransactions`. - - Removed public methods `startIncomingTransactionPolling`, `stopIncomingTransactionPolling`, `updateIncomingTransactions`. - - Removed event `TransactionController:incomingTransactionsReceived`. - - Removed exported constant `INCOMING_TRANSACTIONS_SUPPORTED_CHAIN_IDS`. - - Removed exported types `TransactionControllerIncomingTransactionsReceivedEvent`, `TransactionControllerStartIncomingTransactionPollingAction`, `TransactionControllerStopIncomingTransactionPollingAction`, `TransactionControllerUpdateIncomingTransactionsAction`. - - Removed unused exports `getAccountTransactions`, `TransactionResponse`, `GetAccountTransactionsRequest`, `GetAccountTransactionsResponse` from the accounts API module. - - Fields on `TransactionMeta` related to incoming transactions are preserved. -- **BREAKING:** `MultichainTrackingHelper` no longer initializes synchronously in its constructor ([#9012](https://github.com/MetaMask/core/pull/9012)) - - Initialization is now deferred: the helper polls until `NetworkController` is available on the messenger, allowing `TransactionController` to be constructed before `NetworkController` is registered. - ### Changed +- `TransactionController` can now be constructed before `NetworkController` is registered on the messenger ([#9012](https://github.com/MetaMask/core/pull/9012)) + - Network client tracking initializes automatically once `NetworkController` becomes available; no explicit call required from the consumer. + - Transaction history trimming is skipped when the history limit feature flag is unavailable. - **BREAKING:** Remove deprecated `TransactionController` constructor options and unused hooks, and replace them with direct messenger calls ([#8983](https://github.com/MetaMask/core/pull/8983)) - Removed options: `disableHistory`, `disableSendFlowHistory`, `getCurrentAccountEIP1559Compatibility`, `getCurrentNetworkEIP1559Compatibility`, `getExternalPendingTransactions`, `getGasFeeEstimates`, `getNetworkClientRegistry`, `getNetworkState`, `pendingTransactions`, `securityProviderRequest`, `sign`, `transactionHistoryLimit` - Removed hooks: `afterSign`, `afterSimulate`, `getAdditionalSignArguments` @@ -31,6 +20,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added required `AllowedActions`: `GasFeeController:fetchGasFeeEstimates`, `KeyringController:signTransaction`, `NetworkController:getEIP1559Compatibility`, `NetworkController:getNetworkClientRegistry`, `NetworkController:getState` - Removed resubmit logic from `PendingTransactionTracker` +### Removed + +- **BREAKING:** Remove incoming transaction support from `TransactionController` ([#9012](https://github.com/MetaMask/core/pull/9012)) + - Removed constructor option `incomingTransactions`. + - Removed public methods `startIncomingTransactionPolling`, `stopIncomingTransactionPolling`, `updateIncomingTransactions`. + - Removed event `TransactionController:incomingTransactionsReceived`. + - Removed exported constant `INCOMING_TRANSACTIONS_SUPPORTED_CHAIN_IDS`. + - Removed exported types `TransactionControllerIncomingTransactionsReceivedEvent`, `TransactionControllerStartIncomingTransactionPollingAction`, `TransactionControllerStopIncomingTransactionPollingAction`, `TransactionControllerUpdateIncomingTransactionsAction`, `TransactionResponse`, `GetAccountTransactionsRequest`, `GetAccountTransactionsResponse`. + - Fields on `TransactionMeta` related to incoming transactions are preserved. + ## [66.0.1] ### Changed diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 1b68fa2f68..f0a50118ef 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -21,7 +21,6 @@ import { convertHexToDecimal, } from '@metamask/controller-utils'; import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; - import type { AccountActivityServiceTransactionUpdatedEvent } from '@metamask/core-backend'; import type { FetchGasFeeEstimateOptions, diff --git a/packages/transaction-controller/src/api/accounts-api.test.ts b/packages/transaction-controller/src/api/accounts-api.test.ts index defef88908..fbcc6fc196 100644 --- a/packages/transaction-controller/src/api/accounts-api.test.ts +++ b/packages/transaction-controller/src/api/accounts-api.test.ts @@ -106,5 +106,4 @@ describe('Accounts API', () => { }); }); }); - }); diff --git a/packages/transaction-controller/src/api/accounts-api.ts b/packages/transaction-controller/src/api/accounts-api.ts index 6a86cd8db8..69fd323511 100644 --- a/packages/transaction-controller/src/api/accounts-api.ts +++ b/packages/transaction-controller/src/api/accounts-api.ts @@ -109,5 +109,3 @@ export async function getAccountAddressRelationship( return responseJson; } - - diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts index 8248338575..624d73ff18 100644 --- a/packages/transaction-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-controller/src/utils/feature-flags.test.ts @@ -843,6 +843,4 @@ describe('Feature Flags Utils', () => { expect(getTimeoutAttempts(CHAIN_ID_MOCK, controllerMessenger)).toBe(0); }); }); - }); - From 98e4dbb14eb3cce7e4c1318bbe5ed067faffb968 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 5 Jun 2026 12:15:47 +0100 Subject: [PATCH 6/8] fix: remove TransactionControllerIncomingTransactionsReceivedEvent from TokenBalancesController --- .../src/TokenBalancesController.test.ts | 17 ----------------- .../src/TokenBalancesController.ts | 19 +++---------------- 2 files changed, 3 insertions(+), 33 deletions(-) diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 20240ca08d..d20d988e1a 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -125,7 +125,6 @@ const setupController = ({ 'AccountActivityService:statusChanged', 'AccountsController:selectedEvmAccountChange', 'TransactionController:transactionConfirmed', - 'TransactionController:incomingTransactionsReceived', ], }); @@ -6129,22 +6128,6 @@ describe('TokenBalancesController', () => { }); }); - it('should handle TransactionController:incomingTransactionsReceived event', async () => { - const { controller, messenger } = setupController(); - const updateBalancesSpy = jest.spyOn(controller, 'updateBalances'); - - messenger.publish('TransactionController:incomingTransactionsReceived', [ - { chainId: '0x1' }, - { chainId: '0x89' }, - ] as unknown as TransactionMeta[]); - - await jest.advanceTimersByTimeAsync(0); - - expect(updateBalancesSpy).toHaveBeenCalledWith({ - chainIds: ['0x1', '0x89'], - }); - }); - it('should handle errors from #onTokensChanged gracefully', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); const { controller, messenger } = setupController(); diff --git a/packages/assets-controllers/src/TokenBalancesController.ts b/packages/assets-controllers/src/TokenBalancesController.ts index bc27b8e1a9..65220d5d10 100644 --- a/packages/assets-controllers/src/TokenBalancesController.ts +++ b/packages/assets-controllers/src/TokenBalancesController.ts @@ -42,10 +42,7 @@ import type { PreferencesControllerStateChangeEvent, } from '@metamask/preferences-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; -import type { - TransactionControllerIncomingTransactionsReceivedEvent, - TransactionControllerTransactionConfirmedEvent, -} from '@metamask/transaction-controller'; +import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { isCaipAssetType, @@ -195,8 +192,7 @@ export type AllowedEvents = | AccountActivityServiceBalanceUpdatedEvent | AccountActivityServiceStatusChangedEvent | AccountsControllerSelectedEvmAccountChangeEvent - | TransactionControllerTransactionConfirmedEvent - | TransactionControllerIncomingTransactionsReceivedEvent; + | TransactionControllerTransactionConfirmedEvent; export type TokenBalancesControllerMessenger = Messenger< typeof CONTROLLER, @@ -496,16 +492,7 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ }, ); - this.messenger.subscribe( - 'TransactionController:incomingTransactionsReceived', - (incomingTransactions) => { - this.updateBalances({ - chainIds: incomingTransactions.map((tx) => tx.chainId), - }).catch(() => { - // Silently handle balance update errors - }); - }, - ); + } /** From 1b249df98687975297b123b61a2f43226b07962b Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 5 Jun 2026 12:17:50 +0100 Subject: [PATCH 7/8] chore: add assets-controllers changelog entry for removed event --- packages/assets-controllers/CHANGELOG.md | 4 ++++ packages/assets-controllers/src/TokenBalancesController.ts | 2 -- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index d582269c0e..2871ab5eb1 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/network-enablement-controller` from `^5.2.0` to `^5.3.0` ([#9003](https://github.com/MetaMask/core/pull/9003)) +### Removed + +- **BREAKING:** Remove `TransactionController:incomingTransactionsReceived` from `TokenBalancesControllerMessenger` allowed events ([#9012](https://github.com/MetaMask/core/pull/9012)) + ## [108.5.0] ### Added diff --git a/packages/assets-controllers/src/TokenBalancesController.ts b/packages/assets-controllers/src/TokenBalancesController.ts index 65220d5d10..8779ac3690 100644 --- a/packages/assets-controllers/src/TokenBalancesController.ts +++ b/packages/assets-controllers/src/TokenBalancesController.ts @@ -491,8 +491,6 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ }); }, ); - - } /** From c5f8cd0af6d221e7d0e38b75fca2ac7791a7b6b6 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 5 Jun 2026 12:24:50 +0100 Subject: [PATCH 8/8] fix: remove TransactionControllerIncomingTransactionsReceivedEvent from assets-controller --- packages/assets-controller/CHANGELOG.md | 4 ++ .../assets-controller/src/AssetsController.ts | 2 - .../MockAssetControllerMessenger.ts | 1 - .../src/data-sources/RpcDataSource.test.ts | 36 ----------- .../src/data-sources/RpcDataSource.ts | 32 +--------- .../StakedBalanceDataSource.test.ts | 62 ------------------- .../data-sources/StakedBalanceDataSource.ts | 42 ------------- 7 files changed, 5 insertions(+), 174 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index b6b65e39b4..5586f8fb5c 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/network-enablement-controller` from `^5.2.0` to `^5.3.0` ([#9003](https://github.com/MetaMask/core/pull/9003)) +### Removed + +- **BREAKING:** Remove `TransactionController:incomingTransactionsReceived` from `AssetsController` allowed events and `RpcDataSourceAllowedEvents`; remove associated balance refresh on incoming transactions ([#9012](https://github.com/MetaMask/core/pull/9012)) + ## [8.3.2] ### Changed diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 9151802eda..da209a8262 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -49,7 +49,6 @@ import type { SnapControllerSnapInstalledEvent, } from '@metamask/snaps-controllers'; import type { - TransactionControllerIncomingTransactionsReceivedEvent, TransactionControllerTransactionConfirmedEvent, TransactionControllerUnapprovedTransactionAddedEvent, TransactionMeta, @@ -333,7 +332,6 @@ type AllowedEvents = | NetworkControllerNetworkAddedEvent | NetworkControllerNetworkRemovedEvent | TransactionControllerTransactionConfirmedEvent - | TransactionControllerIncomingTransactionsReceivedEvent // StakedBalanceDataSource | NetworkEnablementControllerEvents // SnapDataSource diff --git a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts index 70cdf82907..793d3e79e5 100644 --- a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts +++ b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts @@ -85,7 +85,6 @@ export function createMockAssetControllerMessenger(): { // RpcDataSource, StakedBalanceDataSource 'NetworkController:stateChange', 'TransactionController:transactionConfirmed', - 'TransactionController:incomingTransactionsReceived', // StakedBalanceDataSource 'NetworkEnablementController:stateChange', // SnapDataSource diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index b39113e7a5..4c35377b10 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -1904,42 +1904,6 @@ describe('RpcDataSource', () => { expect(true).toBe(true); }); }); - - it('refreshes balance when incoming transactions received', async () => { - await withController(async ({ controller, rootMessenger }) => { - await controller.subscribe({ - request: createDataRequest(), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate: jest.fn(), - }); - - rootMessenger.publish( - 'TransactionController:incomingTransactionsReceived', - [{ chainId: MOCK_CHAIN_ID_HEX }] as unknown as TransactionMeta[], - ); - await new Promise(process.nextTick); - expect(controller).toBeDefined(); - }); - }); - - it('refreshes all active chains when incoming transactions empty', async () => { - await withController(async ({ controller, rootMessenger }) => { - await controller.subscribe({ - request: createDataRequest(), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate: jest.fn(), - }); - - rootMessenger.publish( - 'TransactionController:incomingTransactionsReceived', - [] as TransactionMeta[], - ); - await new Promise(process.nextTick); - expect(controller).toBeDefined(); - }); - }); }); describe('network state change', () => { diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index 2bcb742c65..66ab0caa4f 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -9,7 +9,6 @@ import type { NetworkStatus, } from '@metamask/network-controller'; import type { - TransactionControllerIncomingTransactionsReceivedEvent, TransactionControllerTransactionConfirmedEvent, TransactionMeta, } from '@metamask/transaction-controller'; @@ -78,8 +77,7 @@ export type RpcDataSourceAllowedActions = // Allowed events that RpcDataSource can subscribe to export type RpcDataSourceAllowedEvents = | NetworkControllerStateChangeEvent - | TransactionControllerTransactionConfirmedEvent - | TransactionControllerIncomingTransactionsReceivedEvent; + | TransactionControllerTransactionConfirmedEvent; /** Network status for each chain */ export type ChainStatus = { @@ -234,8 +232,6 @@ export class RpcDataSource extends AbstractDataSource< #unsubscribeTransactionConfirmed: (() => void) | undefined = undefined; - #unsubscribeIncomingTransactions: (() => void) | undefined = undefined; - // Rpc-datasource components readonly #multicallClient: MulticallClient; @@ -637,13 +633,6 @@ export class RpcDataSource extends AbstractDataSource< ); this.#unsubscribeTransactionConfirmed = typeof unsubConfirmed === 'function' ? unsubConfirmed : undefined; - - const unsubIncoming = this.#messenger.subscribe( - 'TransactionController:incomingTransactionsReceived', - this.#onIncomingTransactions.bind(this), - ); - this.#unsubscribeIncomingTransactions = - typeof unsubIncoming === 'function' ? unsubIncoming : undefined; } #onTransactionConfirmed(payload: TransactionMeta): void { @@ -657,24 +646,6 @@ export class RpcDataSource extends AbstractDataSource< }); } - #onIncomingTransactions(payload: TransactionMeta[]): void { - const chainIds = Array.from( - new Set( - (payload ?? []) - .map((item) => item?.chainId) - .filter((id): id is Hex => Boolean(id)), - ), - ); - const caipChainIds = chainIds.map( - (hexChainId) => `eip155:${parseInt(hexChainId, 16)}` as ChainId, - ); - const toRefresh = - caipChainIds.length > 0 ? caipChainIds : [...this.#activeChains]; - this.#refreshBalanceForChains(toRefresh).catch((error) => { - log('Failed to refresh balance after incoming transactions', { error }); - }); - } - /** * Fetch balances for the given chains across all active subscriptions and * push updates to the controller. @@ -1502,7 +1473,6 @@ export class RpcDataSource extends AbstractDataSource< log('Destroying RpcDataSource'); this.#unsubscribeTransactionConfirmed?.(); - this.#unsubscribeIncomingTransactions?.(); // Stop all polling this.#balanceFetcher.stopAllPolling(); diff --git a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts index 321fc4a465..358dcbc534 100644 --- a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts @@ -220,10 +220,6 @@ describe('StakedBalanceDataSource', () => { 'TransactionController:transactionConfirmed', expect.any(Function), ); - expect(mockMessengerSubscribe).toHaveBeenCalledWith( - 'TransactionController:incomingTransactionsReceived', - expect.any(Function), - ); expect(mockMessengerSubscribe).toHaveBeenCalledWith( 'NetworkController:stateChange', expect.any(Function), @@ -492,64 +488,6 @@ describe('StakedBalanceDataSource', () => { expect(onAssetsUpdate).toHaveBeenCalledTimes(1); }); }); - - it('refreshes when incomingTransactionsReceived includes tx involving staking contract', async () => { - await withController(async ({ controller, rootMessenger }) => { - // Arrange - const onAssetsUpdate = await arrange({ controller }); - - // Act - rootMessenger.publish( - 'TransactionController:incomingTransactionsReceived', - [ - { - id: '1', - networkClientId: 'mainnet', - status: TransactionStatus.confirmed, - time: Date.now(), - chainId: MAINNET_CHAIN_ID_HEX, - txParams: { - to: STAKING_CONTRACT_MAINNET, - from: '0x0000000000000000000000000000000000000000', - }, - }, - ], - ); - - // Assert - await new Promise((resolve) => setTimeout(resolve, 300)); - expect(onAssetsUpdate).toHaveBeenCalledTimes(1); - }); - }); - - it('does not refresh when incomingTransactionsReceived has no tx involving staking contract', async () => { - await withController(async ({ controller, rootMessenger }) => { - // Arrange - const onAssetsUpdate = await arrange({ controller }); - - // Act - rootMessenger.publish( - 'TransactionController:incomingTransactionsReceived', - [ - { - id: '1', - networkClientId: 'mainnet', - status: TransactionStatus.confirmed, - time: Date.now(), - chainId: MAINNET_CHAIN_ID_HEX, - txParams: { - to: '0x1234567890123456789012345678901234567890', - from: '0x0000000000000000000000000000000000000000', - }, - }, - ], - ); - - // Assert - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(onAssetsUpdate).not.toHaveBeenCalled(); - }); - }); }); describe('refreshStakedBalance', () => { diff --git a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts index 4adaa79cd3..df747c5495 100644 --- a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts +++ b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts @@ -206,11 +206,6 @@ export class StakedBalanceDataSource extends AbstractDataSource< this.#onTransactionConfirmed.bind(this), ); - this.#messenger.subscribe( - 'TransactionController:incomingTransactionsReceived', - this.#onIncomingTransactions.bind(this), - ); - this.#messenger.subscribe( 'NetworkController:stateChange', this.#onNetworkStateChange.bind(this), @@ -312,43 +307,6 @@ export class StakedBalanceDataSource extends AbstractDataSource< } } - /** - * When incoming transactions are received, refresh staked balance only for - * chains where at least one transaction is from or to the staking contract. - * - * @param payload - From TransactionController:incomingTransactionsReceived (array of { chainId?, txParams? }). - */ - #onIncomingTransactions( - payload: { chainId?: string; txParams?: { from?: string; to?: string } }[], - ): void { - if (!this.#enabled) { - return; - } - const chainIdsToRefresh = new Set(); - for (const item of payload ?? []) { - if (!item?.chainId) { - continue; - } - if (this.#isTransactionInvolvingStakingContract(item)) { - chainIdsToRefresh.add(item.chainId); - } - } - const caipChainIds = [...chainIdsToRefresh].map( - (hexChainId) => `eip155:${parseInt(hexChainId, 16)}` as ChainId, - ); - if (caipChainIds.length === 0) { - return; - } - const toRefresh = this.#getToRefreshForChains(caipChainIds); - if (toRefresh.length > 0) { - this.#refreshStakedBalanceAfterTransaction(toRefresh).catch((error) => { - log('Failed to refresh staked balance after incoming transactions', { - error, - }); - }); - } - } - /** * Build toRefresh list for subscribed (account, chainId) pairs for the given chains. *