From 00b6cfa3d39f78f1280421ef82093ac9a5e4ca7e Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 21 Jan 2026 17:17:25 +0530 Subject: [PATCH 01/10] feat: implement enhanced transaction history retrieval --- packages/transaction-controller/package.json | 1 + .../src/TransactionController.ts | 5 +- .../helpers/IncomingTransactionHelper.test.ts | 403 +++++++++++++++++- .../src/helpers/IncomingTransactionHelper.ts | 93 +++- .../src/utils/feature-flags.test.ts | 43 ++ .../src/utils/feature-flags.ts | 22 + .../tsconfig.build.json | 1 + packages/transaction-controller/tsconfig.json | 1 + yarn.lock | 1 + 9 files changed, 556 insertions(+), 14 deletions(-) diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 50bfc970df2..f305357af35 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -59,6 +59,7 @@ "@metamask/approval-controller": "^8.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.18.0", + "@metamask/core-backend": "^5.0.0", "@metamask/eth-query": "^4.0.0", "@metamask/gas-fee-controller": "^26.0.2", "@metamask/messenger": "^0.3.0", diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 2f5f942d70e..f0758eb6d1c 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -22,6 +22,7 @@ import { convertHexToDecimal, } from '@metamask/controller-utils'; import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; +import type { AccountActivityServiceTransactionUpdatedEvent } from '@metamask/core-backend'; import EthQuery from '@metamask/eth-query'; import type { FetchGasFeeEstimateOptions, @@ -602,7 +603,9 @@ export type AllowedActions = /** * The external events available to the {@link TransactionController}. */ -export type AllowedEvents = NetworkControllerStateChangeEvent; +export type AllowedEvents = + | AccountActivityServiceTransactionUpdatedEvent + | NetworkControllerStateChangeEvent; /** * Represents the `TransactionController:stateChange` event. diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index 9cd2f9ecf97..b67b1dbfb58 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -1,3 +1,4 @@ +import type { Transaction as AccountActivityTransaction } from '@metamask/core-backend'; import type { Hex } from '@metamask/utils'; import { IncomingTransactionHelper } from './IncomingTransactionHelper'; @@ -5,7 +6,10 @@ import type { TransactionControllerMessenger } from '..'; import { flushPromises } from '../../../../tests/helpers'; import { TransactionStatus, TransactionType } from '../types'; import type { RemoteTransactionSource, TransactionMeta } from '../types'; -import { getIncomingTransactionsPollingInterval } from '../utils/feature-flags'; +import { + getIncomingTransactionsPollingInterval, + isEnhancedHistoryRetrievalEnabled, +} from '../utils/feature-flags'; jest.useFakeTimers(); @@ -131,6 +135,8 @@ describe('IncomingTransactionHelper', () => { jest .mocked(getIncomingTransactionsPollingInterval) .mockReturnValue(1000 * 30); + + jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); }); describe('on interval', () => { @@ -507,4 +513,399 @@ describe('IncomingTransactionHelper', () => { ); }); }); + + describe('enhanced history retrieval mode', () => { + let subscribeMock: jest.Mock; + let unsubscribeMock: jest.Mock; + let transactionUpdatedHandler: (tx: AccountActivityTransaction) => void; + + beforeEach(() => { + jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(true); + + subscribeMock = jest.fn().mockImplementation((_event, handler) => { + transactionUpdatedHandler = handler; + }); + unsubscribeMock = jest.fn(); + }); + + function createMessengerMock(): TransactionControllerMessenger { + return { + subscribe: subscribeMock, + unsubscribe: unsubscribeMock, + } as unknown as TransactionControllerMessenger; + } + + describe('start', () => { + it('calls update once and subscribes to transactionUpdated event', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource, + }); + + helper.start(); + await flushPromises(); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( + 1, + ); + expect(subscribeMock).toHaveBeenCalledWith( + 'AccountActivityService:transactionUpdated', + expect.any(Function), + ); + }); + + it('does not start polling interval', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + helper.start(); + await flushPromises(); + + expect(jest.getTimerCount()).toBe(0); + }); + + it('does nothing if already started', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource, + }); + + helper.start(); + await flushPromises(); + + helper.start(); + + expect(subscribeMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('stop', () => { + it('unsubscribes from transactionUpdated event', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + helper.start(); + await flushPromises(); + + helper.stop(); + + expect(unsubscribeMock).toHaveBeenCalledWith( + 'AccountActivityService:transactionUpdated', + expect.any(Function), + ); + }); + + it('does not call unsubscribe if not started', () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + helper.stop(); + + expect(unsubscribeMock).not.toHaveBeenCalled(); + }); + }); + + describe('on transactionUpdated event', () => { + it('triggers update when transaction is to current account', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource, + }); + + helper.start(); + 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('triggers update when transaction is from current account', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource, + }); + + helper.start(); + await flushPromises(); + + jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); + + transactionUpdatedHandler({ + id: 'tx-123', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: ADDRESS_MOCK, + to: '0xother', + }); + + await flushPromises(); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( + 1, + ); + }); + + it('ignores transaction not for current account', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource, + }); + + helper.start(); + await flushPromises(); + + jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); + + transactionUpdatedHandler({ + id: 'tx-123', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0xother1', + to: '0xother2', + }); + + await flushPromises(); + + expect( + remoteTransactionSource.fetchTransactions, + ).not.toHaveBeenCalled(); + }); + + it('handles case-insensitive address comparison', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource, + }); + + helper.start(); + await flushPromises(); + + jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); + + transactionUpdatedHandler({ + id: 'tx-123', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0xother', + to: ADDRESS_MOCK.toUpperCase(), + }); + + await flushPromises(); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( + 1, + ); + }); + }); + + describe('error handling', () => { + it('handles error in initial update gracefully', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([], { + error: true, + }); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource, + }); + + helper.start(); + await flushPromises(); + + expect(subscribeMock).toHaveBeenCalled(); + }); + + it('handles error in update after transaction event gracefully', async () => { + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource, + }); + + helper.start(); + await flushPromises(); + + jest + .mocked(remoteTransactionSource.fetchTransactions) + .mockRejectedValueOnce(new Error('Test Error')); + + transactionUpdatedHandler({ + id: 'tx-123', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0xother', + to: ADDRESS_MOCK, + }); + + await flushPromises(); + + expect(helper).toBeDefined(); + }); + }); + }); + + describe('legacy polling mode', () => { + it('uses polling when enhanced mode is disabled', async () => { + jest.mocked(isEnhancedHistoryRetrievalEnabled).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(isEnhancedHistoryRetrievalEnabled).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(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); + + const remoteTransactionSource = createRemoteTransactionSourceMock([], { + error: true, + }); + + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + remoteTransactionSource, + }); + + helper.start(); + await flushPromises(); + + expect(helper).toBeDefined(); + }); + + // eslint-disable-next-line jest/expect-expect + it('handles error in polling interval gracefully', async () => { + jest.mocked(isEnhancedHistoryRetrievalEnabled).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(isEnhancedHistoryRetrievalEnabled).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) => + transactions.filter((tx) => tx.id !== TRANSACTION_MOCK_2.id), + }); + + helper.hub.on('transactions', listener); + + await helper.update(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 180fc3dd034..3ffbc2a5f4a 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -1,4 +1,5 @@ import type { AccountsController } from '@metamask/accounts-controller'; +import type { Transaction as AccountActivityTransaction } 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 @@ -7,7 +8,10 @@ import EventEmitter from 'events'; import type { TransactionControllerMessenger } from '..'; import { incomingTransactionsLogger as log } from '../logger'; import type { RemoteTransactionSource, TransactionMeta } from '../types'; -import { getIncomingTransactionsPollingInterval } from '../utils/feature-flags'; +import { + getIncomingTransactionsPollingInterval, + isEnhancedHistoryRetrievalEnabled, +} from '../utils/feature-flags'; export type IncomingTransactionOptions = { /** Name of the client to include in requests. */ @@ -59,6 +63,12 @@ export class IncomingTransactionHelper { readonly #updateTransactions?: boolean; + readonly #isEnhancedHistoryEnabled: boolean; + + #transactionUpdatedHandler?: ( + transaction: AccountActivityTransaction, + ) => void; + constructor({ client, getCurrentAccount, @@ -95,6 +105,8 @@ export class IncomingTransactionHelper { this.#remoteTransactionSource = remoteTransactionSource; this.#trimTransactions = trimTransactions; this.#updateTransactions = updateTransactions; + this.#isEnhancedHistoryEnabled = + isEnhancedHistoryRetrievalEnabled(messenger); } start(): void { @@ -106,19 +118,13 @@ export class IncomingTransactionHelper { return; } - const interval = this.#getInterval(); - - log('Started polling', { interval }); - this.#isRunning = true; - if (this.#isUpdating) { - return; + if (this.#isEnhancedHistoryEnabled) { + this.#startEnhancedMode(); + } else { + this.#startPollingMode(); } - - this.#onInterval().catch((error) => { - log('Initial polling failed', error); - }); } stop(): void { @@ -126,13 +132,76 @@ export class IncomingTransactionHelper { clearTimeout(this.#timeoutId as number); } + if (this.#transactionUpdatedHandler) { + this.#messenger.unsubscribe( + 'AccountActivityService:transactionUpdated', + this.#transactionUpdatedHandler, + ); + this.#transactionUpdatedHandler = undefined; + } + if (!this.#isRunning) { return; } this.#isRunning = false; - log('Stopped polling'); + log('Stopped'); + } + + #startPollingMode(): void { + const interval = this.#getInterval(); + + log('Started polling', { interval }); + + if (this.#isUpdating) { + return; + } + + this.#onInterval().catch((error) => { + log('Initial polling failed', error); + }); + } + + #startEnhancedMode(): void { + log('Started enhanced mode (event-driven)'); + + this.update().catch((error) => { + log('Initial update in enhanced mode failed', error); + }); + + this.#transactionUpdatedHandler = ( + transaction: AccountActivityTransaction, + ): void => { + this.#onTransactionUpdated(transaction); + }; + + this.#messenger.subscribe( + 'AccountActivityService:transactionUpdated', + this.#transactionUpdatedHandler, + ); + } + + #onTransactionUpdated(transaction: AccountActivityTransaction): void { + const currentAccount = this.#getCurrentAccount(); + const currentAddress = currentAccount?.address?.toLowerCase(); + + const txTo = transaction.to?.toLowerCase(); + const txFrom = transaction.from?.toLowerCase(); + + if ( + currentAddress && + (txTo === currentAddress || txFrom === currentAddress) + ) { + log('Received relevant transaction update, triggering update', { + txId: transaction.id, + chain: transaction.chain, + }); + + this.update().catch((error) => { + log('Update after transaction event failed', error); + }); + } } async #onInterval(): Promise { diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts index 3706c703ca7..ca9f333f13a 100644 --- a/packages/transaction-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-controller/src/utils/feature-flags.test.ts @@ -22,6 +22,7 @@ import { FeatureFlag, getIncomingTransactionsPollingInterval, getTimeoutAttempts, + isEnhancedHistoryRetrievalEnabled, } from './feature-flags'; import { isValidSignature } from './signature'; import type { TransactionControllerMessenger } from '..'; @@ -866,4 +867,46 @@ describe('Feature Flags Utils', () => { expect(getTimeoutAttempts(CHAIN_ID_MOCK, controllerMessenger)).toBe(0); }); }); + + describe('isEnhancedHistoryRetrievalEnabled', () => { + it('returns true when enabled is true', () => { + mockFeatureFlags({ + [FeatureFlag.EnhancedHistoryRetrieval]: { + enabled: true, + }, + }); + + expect(isEnhancedHistoryRetrievalEnabled(controllerMessenger)).toBe(true); + }); + + it('returns false when enabled is false', () => { + mockFeatureFlags({ + [FeatureFlag.EnhancedHistoryRetrieval]: { + enabled: false, + }, + }); + + expect(isEnhancedHistoryRetrievalEnabled(controllerMessenger)).toBe( + false, + ); + }); + + it('returns false when flag is not present', () => { + mockFeatureFlags({}); + + expect(isEnhancedHistoryRetrievalEnabled(controllerMessenger)).toBe( + false, + ); + }); + + it('returns false when enabled property is not present', () => { + mockFeatureFlags({ + [FeatureFlag.EnhancedHistoryRetrieval]: {}, + }); + + expect(isEnhancedHistoryRetrievalEnabled(controllerMessenger)).toBe( + false, + ); + }); + }); }); diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index a99fba5630c..d87eb432103 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -21,6 +21,7 @@ const DEFAULT_TRANSACTION_HISTORY_LIMIT = 40; */ export enum FeatureFlag { EIP7702 = 'confirmations_eip_7702', + EnhancedHistoryRetrieval = 'enhanced_history_retrieval', GasBuffer = 'confirmations_gas_buffer', IncomingTransactions = 'confirmations_incoming_transactions', Transactions = 'confirmations_transactions', @@ -39,6 +40,12 @@ type GasEstimateFallback = { }; export type TransactionControllerFeatureFlags = { + /** Feature flag to enable enhanced history retrieval using event-driven updates. */ + [FeatureFlag.EnhancedHistoryRetrieval]?: { + /** Whether enhanced history retrieval is enabled. */ + enabled?: boolean; + }; + /** Feature flags to support EIP-7702 / type-4 transactions. */ [FeatureFlag.EIP7702]?: { /** @@ -475,6 +482,21 @@ export function getTimeoutAttempts( ); } +/** + * Checks if enhanced history retrieval is enabled. + * When enabled, incoming transactions are fetched via event-driven updates + * instead of polling. + * + * @param messenger - The controller messenger instance. + * @returns True if enhanced history retrieval is enabled, false otherwise. + */ +export function isEnhancedHistoryRetrievalEnabled( + messenger: TransactionControllerMessenger, +): boolean { + const featureFlags = getFeatureFlags(messenger); + return featureFlags?.[FeatureFlag.EnhancedHistoryRetrieval]?.enabled ?? false; +} + /** * Retrieves the relevant feature flags from the remote feature flag controller. * diff --git a/packages/transaction-controller/tsconfig.build.json b/packages/transaction-controller/tsconfig.build.json index 6e04a4ba1d8..4237bad0410 100644 --- a/packages/transaction-controller/tsconfig.build.json +++ b/packages/transaction-controller/tsconfig.build.json @@ -10,6 +10,7 @@ { "path": "../approval-controller/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../core-backend/tsconfig.build.json" }, { "path": "../gas-fee-controller/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, diff --git a/packages/transaction-controller/tsconfig.json b/packages/transaction-controller/tsconfig.json index 1e328031877..99b7f1f98e0 100644 --- a/packages/transaction-controller/tsconfig.json +++ b/packages/transaction-controller/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../approval-controller" }, { "path": "../base-controller" }, { "path": "../controller-utils" }, + { "path": "../core-backend" }, { "path": "../gas-fee-controller" }, { "path": "../network-controller" }, { "path": "../messenger" }, diff --git a/yarn.lock b/yarn.lock index e9d066c5d49..7bfc4d80f00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5096,6 +5096,7 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/core-backend": "npm:^5.0.0" "@metamask/eth-block-tracker": "npm:^15.0.0" "@metamask/eth-json-rpc-provider": "npm:^6.0.0" "@metamask/eth-query": "npm:^4.0.0" From e2e00357fff0cdbb0f1646786d8155a6478fdf83 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 21 Jan 2026 18:53:34 +0530 Subject: [PATCH 02/10] update --- .../src/helpers/IncomingTransactionHelper.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 3ffbc2a5f4a..689fda1739b 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -65,9 +65,11 @@ export class IncomingTransactionHelper { readonly #isEnhancedHistoryEnabled: boolean; - #transactionUpdatedHandler?: ( + #transactionUpdatedHandler = ( transaction: AccountActivityTransaction, - ) => void; + ): void => { + this.#onTransactionUpdated(transaction); + }; constructor({ client, @@ -132,13 +134,10 @@ export class IncomingTransactionHelper { clearTimeout(this.#timeoutId as number); } - if (this.#transactionUpdatedHandler) { - this.#messenger.unsubscribe( - 'AccountActivityService:transactionUpdated', - this.#transactionUpdatedHandler, - ); - this.#transactionUpdatedHandler = undefined; - } + this.#messenger.unsubscribe( + 'AccountActivityService:transactionUpdated', + this.#transactionUpdatedHandler, + ); if (!this.#isRunning) { return; @@ -170,12 +169,6 @@ export class IncomingTransactionHelper { log('Initial update in enhanced mode failed', error); }); - this.#transactionUpdatedHandler = ( - transaction: AccountActivityTransaction, - ): void => { - this.#onTransactionUpdated(transaction); - }; - this.#messenger.subscribe( 'AccountActivityService:transactionUpdated', this.#transactionUpdatedHandler, From df41a2c571cc22f7dcc31e41868469a673a47d50 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 22 Jan 2026 16:11:34 +0530 Subject: [PATCH 03/10] update --- .../helpers/IncomingTransactionHelper.test.ts | 76 +++---------------- .../src/helpers/IncomingTransactionHelper.ts | 27 ++----- 2 files changed, 19 insertions(+), 84 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index b67b1dbfb58..e0fe43d765e 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -21,7 +21,10 @@ console.error = jest.fn(); const CHAIN_ID_MOCK = '0x1' as const; const ADDRESS_MOCK = '0x1'; const SYSTEM_TIME_MOCK = 1000 * 60 * 60 * 24 * 2; -const MESSENGER_MOCK = {} as unknown as TransactionControllerMessenger; +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'; @@ -607,7 +610,7 @@ describe('IncomingTransactionHelper', () => { ); }); - it('does not call unsubscribe if not started', () => { + it('calls unsubscribe even if not started', () => { const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, messenger: createMessengerMock(), @@ -616,12 +619,15 @@ describe('IncomingTransactionHelper', () => { helper.stop(); - expect(unsubscribeMock).not.toHaveBeenCalled(); + expect(unsubscribeMock).toHaveBeenCalledWith( + 'AccountActivityService:transactionUpdated', + expect.any(Function), + ); }); }); describe('on transactionUpdated event', () => { - it('triggers update when transaction is to current account', async () => { + it('triggers update when transactionUpdated event is received', async () => { const remoteTransactionSource = createRemoteTransactionSourceMock([]); const helper = new IncomingTransactionHelper({ @@ -651,37 +657,7 @@ describe('IncomingTransactionHelper', () => { ); }); - it('triggers update when transaction is from current account', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource, - }); - - helper.start(); - await flushPromises(); - - jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); - - transactionUpdatedHandler({ - id: 'tx-123', - chain: 'eip155:1', - status: 'confirmed', - timestamp: Date.now(), - from: ADDRESS_MOCK, - to: '0xother', - }); - - await flushPromises(); - - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( - 1, - ); - }); - - it('ignores transaction not for current account', async () => { + it('triggers update for any transaction regardless of addresses', async () => { const remoteTransactionSource = createRemoteTransactionSourceMock([]); const helper = new IncomingTransactionHelper({ @@ -706,36 +682,6 @@ describe('IncomingTransactionHelper', () => { await flushPromises(); - expect( - remoteTransactionSource.fetchTransactions, - ).not.toHaveBeenCalled(); - }); - - it('handles case-insensitive address comparison', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource, - }); - - helper.start(); - await flushPromises(); - - jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); - - transactionUpdatedHandler({ - id: 'tx-123', - chain: 'eip155:1', - status: 'confirmed', - timestamp: Date.now(), - from: '0xother', - to: ADDRESS_MOCK.toUpperCase(), - }); - - await flushPromises(); - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( 1, ); diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 689fda1739b..a782f5725b1 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -176,25 +176,14 @@ export class IncomingTransactionHelper { } #onTransactionUpdated(transaction: AccountActivityTransaction): void { - const currentAccount = this.#getCurrentAccount(); - const currentAddress = currentAccount?.address?.toLowerCase(); - - const txTo = transaction.to?.toLowerCase(); - const txFrom = transaction.from?.toLowerCase(); - - if ( - currentAddress && - (txTo === currentAddress || txFrom === currentAddress) - ) { - log('Received relevant transaction update, triggering update', { - txId: transaction.id, - chain: transaction.chain, - }); - - this.update().catch((error) => { - log('Update after transaction event failed', error); - }); - } + log('Received relevant transaction update, triggering update', { + txId: transaction.id, + chain: transaction.chain, + }); + + this.update().catch((error) => { + log('Update after transaction event failed', error); + }); } async #onInterval(): Promise { From 06badba36ed6fc52001fae955bbe10ec53bafd95 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 22 Jan 2026 17:36:13 +0530 Subject: [PATCH 04/10] update --- .../src/TransactionController.ts | 8 +- .../helpers/IncomingTransactionHelper.test.ts | 370 ++++++++++++++---- .../src/helpers/IncomingTransactionHelper.ts | 101 +++-- 3 files changed, 384 insertions(+), 95 deletions(-) diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index f0758eb6d1c..09b42639521 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -3,6 +3,7 @@ import type { AccountsController, AccountsControllerGetSelectedAccountAction, AccountsControllerGetStateAction, + AccountsControllerSelectedAccountChangeEvent, } from '@metamask/accounts-controller'; import type { AcceptResultCallbacks, @@ -22,7 +23,10 @@ import { convertHexToDecimal, } from '@metamask/controller-utils'; import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; -import type { AccountActivityServiceTransactionUpdatedEvent } from '@metamask/core-backend'; +import type { + AccountActivityServiceTransactionUpdatedEvent, + BackendWebSocketServiceConnectionStateChangedEvent, +} from '@metamask/core-backend'; import EthQuery from '@metamask/eth-query'; import type { FetchGasFeeEstimateOptions, @@ -605,6 +609,8 @@ export type AllowedActions = */ export type AllowedEvents = | AccountActivityServiceTransactionUpdatedEvent + | AccountsControllerSelectedAccountChangeEvent + | BackendWebSocketServiceConnectionStateChangedEvent | NetworkControllerStateChangeEvent; /** diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index e0fe43d765e..d25e2754fca 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -1,4 +1,8 @@ -import type { Transaction as AccountActivityTransaction } from '@metamask/core-backend'; +import type { + Transaction as AccountActivityTransaction, + WebSocketConnectionInfo, +} from '@metamask/core-backend'; +import { WebSocketState } from '@metamask/core-backend'; import type { Hex } from '@metamask/utils'; import { IncomingTransactionHelper } from './IncomingTransactionHelper'; @@ -521,12 +525,20 @@ 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.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(true); - subscribeMock = jest.fn().mockImplementation((_event, handler) => { - transactionUpdatedHandler = handler; + 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(); }); @@ -538,29 +550,78 @@ describe('IncomingTransactionHelper', () => { } as unknown as TransactionControllerMessenger; } - describe('start', () => { - it('calls update once and subscribes to transactionUpdated event', async () => { + 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 enhanced mode 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 call update in constructor when enhanced mode is enabled', async () => { const remoteTransactionSource = createRemoteTransactionSourceMock([]); - const helper = new IncomingTransactionHelper({ + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, messenger: createMessengerMock(), remoteTransactionSource, }); - helper.start(); await flushPromises(); - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( - 1, - ); - expect(subscribeMock).toHaveBeenCalledWith( - 'AccountActivityService:transactionUpdated', + expect( + remoteTransactionSource.fetchTransactions, + ).not.toHaveBeenCalled(); + }); + + it('does not subscribe to connectionStateChanged when enhanced mode is disabled', async () => { + jest.mocked(isEnhancedHistoryRetrievalEnabled).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), ); }); + }); - it('does not start polling interval', async () => { + describe('start', () => { + it('does not start polling when enhanced mode is enabled', async () => { const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, messenger: createMessengerMock(), @@ -572,71 +633,101 @@ describe('IncomingTransactionHelper', () => { expect(jest.getTimerCount()).toBe(0); }); + }); - it('does nothing if already started', async () => { + describe('on WebSocket connected', () => { + it('starts enhanced mode when WebSocket connects', async () => { const remoteTransactionSource = createRemoteTransactionSourceMock([]); - const helper = new IncomingTransactionHelper({ + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, messenger: createMessengerMock(), remoteTransactionSource, }); - helper.start(); await flushPromises(); - helper.start(); + jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); + + connectionStateChangedHandler( + createConnectionInfo(WebSocketState.CONNECTED), + ); + await flushPromises(); - expect(subscribeMock).toHaveBeenCalledTimes(1); + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( + 1, + ); + expect(subscribeMock).toHaveBeenCalledWith( + 'AccountActivityService:transactionUpdated', + expect.any(Function), + ); }); - }); - describe('stop', () => { - it('unsubscribes from transactionUpdated event', async () => { - const helper = new IncomingTransactionHelper({ + it('subscribes to selectedAccountChange when WebSocket connects', async () => { + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, messenger: createMessengerMock(), remoteTransactionSource: createRemoteTransactionSourceMock([]), }); - helper.start(); await flushPromises(); - helper.stop(); + connectionStateChangedHandler( + createConnectionInfo(WebSocketState.CONNECTED), + ); + await flushPromises(); - expect(unsubscribeMock).toHaveBeenCalledWith( - 'AccountActivityService:transactionUpdated', + expect(subscribeMock).toHaveBeenCalledWith( + 'AccountsController:selectedAccountChange', expect.any(Function), ); }); - it('calls unsubscribe even if not started', () => { - const helper = new IncomingTransactionHelper({ + 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: createRemoteTransactionSourceMock([]), + remoteTransactionSource, }); - helper.stop(); + await flushPromises(); - expect(unsubscribeMock).toHaveBeenCalledWith( - 'AccountActivityService:transactionUpdated', - expect.any(Function), + connectionStateChangedHandler( + createConnectionInfo(WebSocketState.CONNECTED), + ); + await flushPromises(); + + jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); + + selectedAccountChangedHandler(); + + await flushPromises(); + + expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( + 1, ); }); - }); - describe('on transactionUpdated event', () => { - it('triggers update when transactionUpdated event is received', async () => { + it('triggers update on transactionUpdated event after WebSocket connects', async () => { const remoteTransactionSource = createRemoteTransactionSourceMock([]); - const helper = new IncomingTransactionHelper({ + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, messenger: createMessengerMock(), remoteTransactionSource, }); - helper.start(); + await flushPromises(); + + connectionStateChangedHandler( + createConnectionInfo(WebSocketState.CONNECTED), + ); await flushPromises(); jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); @@ -657,70 +748,162 @@ describe('IncomingTransactionHelper', () => { ); }); - it('triggers update for any transaction regardless of addresses', async () => { + it('does not start transaction history retrieval if disabled', async () => { const remoteTransactionSource = createRemoteTransactionSourceMock([]); - const helper = new IncomingTransactionHelper({ + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, + isEnabled: (): boolean => false, messenger: createMessengerMock(), remoteTransactionSource, }); - helper.start(); await flushPromises(); - jest.mocked(remoteTransactionSource.fetchTransactions).mockClear(); + connectionStateChangedHandler( + createConnectionInfo(WebSocketState.CONNECTED), + ); + await flushPromises(); - transactionUpdatedHandler({ - id: 'tx-123', - chain: 'eip155:1', - status: 'confirmed', - timestamp: Date.now(), - from: '0xother1', - to: '0xother2', + 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(); - expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes( - 1, + 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), ); }); }); + describe('stop', () => { + it('does not unsubscribe from enhanced mode events because stop only handles polling', async () => { + const helper = new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger: createMessengerMock(), + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + await flushPromises(); + + helper.stop(); + + expect(unsubscribeMock).not.toHaveBeenCalled(); + }); + }); + describe('error handling', () => { - it('handles error in initial update gracefully', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([], { - error: true, + it('handles error in enhanced mode 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(); }); - const helper = new IncomingTransactionHelper({ + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, + getCurrentAccount: getCurrentAccountMock, messenger: createMessengerMock(), - remoteTransactionSource, + remoteTransactionSource: createRemoteTransactionSourceMock([]), }); - helper.start(); await flushPromises(); - expect(subscribeMock).toHaveBeenCalled(); + connectionStateChangedHandler( + createConnectionInfo(WebSocketState.CONNECTED), + ); + await flushPromises(); + + expect(subscribeMock).toHaveBeenCalledWith( + 'AccountActivityService:transactionUpdated', + expect.any(Function), + ); }); - it('handles error in update after transaction event gracefully', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); + 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(); + }); - const helper = new IncomingTransactionHelper({ + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, + getCurrentAccount: getCurrentAccountMock, messenger: createMessengerMock(), - remoteTransactionSource, + remoteTransactionSource: createRemoteTransactionSourceMock([]), }); - helper.start(); await flushPromises(); - jest - .mocked(remoteTransactionSource.fetchTransactions) - .mockRejectedValueOnce(new Error('Test Error')); + connectionStateChangedHandler( + createConnectionInfo(WebSocketState.CONNECTED), + ); + await flushPromises(); transactionUpdatedHandler({ id: 'tx-123', @@ -733,7 +916,39 @@ describe('IncomingTransactionHelper', () => { await flushPromises(); - expect(helper).toBeDefined(); + 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); }); }); }); @@ -792,6 +1007,25 @@ describe('IncomingTransactionHelper', () => { expect(helper).toBeDefined(); }); + it('handles error in initial polling when getCurrentAccount throws', async () => { + jest.mocked(isEnhancedHistoryRetrievalEnabled).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(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); @@ -843,7 +1077,7 @@ describe('IncomingTransactionHelper', () => { remoteTransactionSource: createRemoteTransactionSourceMock([ TRANSACTION_MOCK_2, ]), - trimTransactions: (transactions) => + trimTransactions: (transactions): TransactionMeta[] => transactions.filter((tx) => tx.id !== TRANSACTION_MOCK_2.id), }); diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index a782f5725b1..6863b48ba78 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -1,5 +1,9 @@ import type { AccountsController } from '@metamask/accounts-controller'; -import type { Transaction as AccountActivityTransaction } from '@metamask/core-backend'; +import type { + Transaction as AccountActivityTransaction, + WebSocketConnectionInfo, +} from '@metamask/core-backend'; +import { WebSocketState } 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 @@ -65,12 +69,22 @@ export class IncomingTransactionHelper { readonly #isEnhancedHistoryEnabled: boolean; - #transactionUpdatedHandler = ( + readonly #transactionUpdatedHandler = ( transaction: AccountActivityTransaction, ): void => { this.#onTransactionUpdated(transaction); }; + readonly #connectionStateChangedHandler = ( + connectionInfo: WebSocketConnectionInfo, + ): void => { + this.#onConnectionStateChanged(connectionInfo); + }; + + readonly #selectedAccountChangedHandler = (): void => { + this.#onSelectedAccountChanged(); + }; + constructor({ client, getCurrentAccount, @@ -109,10 +123,17 @@ export class IncomingTransactionHelper { this.#updateTransactions = updateTransactions; this.#isEnhancedHistoryEnabled = isEnhancedHistoryRetrievalEnabled(messenger); + + if (this.#isEnhancedHistoryEnabled) { + this.#messenger.subscribe( + 'BackendWebSocketService:connectionStateChanged', + this.#connectionStateChangedHandler, + ); + } } start(): void { - if (this.#isRunning) { + if (this.#isRunning || this.#isEnhancedHistoryEnabled) { return; } @@ -120,13 +141,19 @@ export class IncomingTransactionHelper { return; } + const interval = this.#getInterval(); + + log('Started polling', { interval }); + this.#isRunning = true; - if (this.#isEnhancedHistoryEnabled) { - this.#startEnhancedMode(); - } else { - this.#startPollingMode(); + if (this.#isUpdating) { + return; } + + this.#onInterval().catch((error) => { + log('Initial polling failed', error); + }); } stop(): void { @@ -134,45 +161,59 @@ export class IncomingTransactionHelper { clearTimeout(this.#timeoutId as number); } - this.#messenger.unsubscribe( - 'AccountActivityService:transactionUpdated', - this.#transactionUpdatedHandler, - ); - if (!this.#isRunning) { return; } this.#isRunning = false; - log('Stopped'); + log('Stopped polling'); } - #startPollingMode(): void { - const interval = this.#getInterval(); - - log('Started polling', { interval }); + #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(); + } + } - if (this.#isUpdating) { + #startTransactionHistoryRetrieval(): void { + if (!this.#canStart()) { return; } - this.#onInterval().catch((error) => { - log('Initial polling failed', error); - }); - } - - #startEnhancedMode(): void { - log('Started enhanced mode (event-driven)'); + log('Started transaction history retrieval (event-driven)'); this.update().catch((error) => { - log('Initial update in enhanced mode failed', 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 { + log('Stopped transaction history retrieval'); + + this.#messenger.unsubscribe( + 'AccountActivityService:transactionUpdated', + this.#transactionUpdatedHandler, + ); + + this.#messenger.unsubscribe( + 'AccountsController:selectedAccountChange', + this.#selectedAccountChangedHandler, + ); } #onTransactionUpdated(transaction: AccountActivityTransaction): void { @@ -186,6 +227,14 @@ export class IncomingTransactionHelper { }); } + #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; From c0c330ee046d619062f804d36db7b19d74b5f3a9 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 22 Jan 2026 17:42:56 +0530 Subject: [PATCH 05/10] update --- .../src/helpers/IncomingTransactionHelper.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 6863b48ba78..9c4ae219692 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -69,18 +69,18 @@ export class IncomingTransactionHelper { readonly #isEnhancedHistoryEnabled: boolean; - readonly #transactionUpdatedHandler = ( - transaction: AccountActivityTransaction, - ): void => { - this.#onTransactionUpdated(transaction); - }; - readonly #connectionStateChangedHandler = ( connectionInfo: WebSocketConnectionInfo, ): void => { this.#onConnectionStateChanged(connectionInfo); }; + readonly #transactionUpdatedHandler = ( + transaction: AccountActivityTransaction, + ): void => { + this.#onTransactionUpdated(transaction); + }; + readonly #selectedAccountChangedHandler = (): void => { this.#onSelectedAccountChanged(); }; From ceeb528a27cdb8c02a22013aa8dbd15613d4ee96 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 22 Jan 2026 17:50:29 +0530 Subject: [PATCH 06/10] update --- .../helpers/IncomingTransactionHelper.test.ts | 35 +------------------ 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index d25e2754fca..6ea07e0577f 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -583,23 +583,6 @@ describe('IncomingTransactionHelper', () => { ); }); - it('does not call update in constructor when enhanced mode is enabled', async () => { - const remoteTransactionSource = createRemoteTransactionSourceMock([]); - - // eslint-disable-next-line no-new - new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource, - }); - - await flushPromises(); - - expect( - remoteTransactionSource.fetchTransactions, - ).not.toHaveBeenCalled(); - }); - it('does not subscribe to connectionStateChanged when enhanced mode is disabled', async () => { jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); const messenger = createMessengerMock(); @@ -636,7 +619,7 @@ describe('IncomingTransactionHelper', () => { }); describe('on WebSocket connected', () => { - it('starts enhanced mode when WebSocket connects', async () => { + it('starts transaction history retrieval when WebSocket connects', async () => { const remoteTransactionSource = createRemoteTransactionSourceMock([]); // eslint-disable-next-line no-new @@ -832,22 +815,6 @@ describe('IncomingTransactionHelper', () => { }); }); - describe('stop', () => { - it('does not unsubscribe from enhanced mode events because stop only handles polling', async () => { - const helper = new IncomingTransactionHelper({ - ...CONTROLLER_ARGS_MOCK, - messenger: createMessengerMock(), - remoteTransactionSource: createRemoteTransactionSourceMock([]), - }); - - await flushPromises(); - - helper.stop(); - - expect(unsubscribeMock).not.toHaveBeenCalled(); - }); - }); - describe('error handling', () => { it('handles error in enhanced mode initial update when getCurrentAccount throws', async () => { let callCount = 0; From 0f635f7370d779a12285342d26039e8c81a33a58 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 22 Jan 2026 18:14:38 +0530 Subject: [PATCH 07/10] update --- .../src/helpers/IncomingTransactionHelper.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index 6ea07e0577f..acce54a0750 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -521,7 +521,7 @@ describe('IncomingTransactionHelper', () => { }); }); - describe('enhanced history retrieval mode', () => { + describe('transaction history retrieval when flag enhanced-history-retrieval is enabled', () => { let subscribeMock: jest.Mock; let unsubscribeMock: jest.Mock; let transactionUpdatedHandler: (tx: AccountActivityTransaction) => void; @@ -565,7 +565,7 @@ describe('IncomingTransactionHelper', () => { } describe('constructor', () => { - it('subscribes to connectionStateChanged when enhanced mode is enabled', async () => { + it('subscribes to connectionStateChanged when flag enhanced-history-retrieval is enabled', async () => { const messenger = createMessengerMock(); // eslint-disable-next-line no-new @@ -583,7 +583,7 @@ describe('IncomingTransactionHelper', () => { ); }); - it('does not subscribe to connectionStateChanged when enhanced mode is disabled', async () => { + it('does not subscribe to connectionStateChanged when flag enhanced-history-retrieval is disabled', async () => { jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); const messenger = createMessengerMock(); @@ -604,7 +604,7 @@ describe('IncomingTransactionHelper', () => { }); describe('start', () => { - it('does not start polling when enhanced mode is enabled', async () => { + it('does not start polling when flag enhanced-history-retrieval is enabled', async () => { const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, messenger: createMessengerMock(), @@ -816,7 +816,7 @@ describe('IncomingTransactionHelper', () => { }); describe('error handling', () => { - it('handles error in enhanced mode initial update when getCurrentAccount throws', async () => { + it('handles error in during transaction history retrieval initial update when getCurrentAccount throws', async () => { let callCount = 0; const getCurrentAccountMock = jest.fn().mockImplementation(() => { callCount += 1; @@ -921,7 +921,7 @@ describe('IncomingTransactionHelper', () => { }); describe('legacy polling mode', () => { - it('uses polling when enhanced mode is disabled', async () => { + it('uses polling when flag enhanced-history-retrieval is disabled', async () => { jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); const helper = new IncomingTransactionHelper({ From bc658fe1c9462163494a917646be2dd5bf8c7636 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 22 Jan 2026 18:18:53 +0530 Subject: [PATCH 08/10] update --- packages/transaction-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index a7a5fb27ac0..8b5bde4f74a 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add support for enhanced transaction history retrieval via WebSocket events ([#7689](https://github.com/MetaMask/core/pull/7689)) - Add support for `submitHistoryLimit` feature flag to configure the maximum number of entries in the submit history ([#7648](https://github.com/MetaMask/core/pull/7648)) - Defaults to 100 if not provided. - Add support for `transactionHistoryLimit` feature flag to configure the maximum number of transactions stored in state ([#7648](https://github.com/MetaMask/core/pull/7648)) From 6ef799ac74f59edf923239a4fbefc43ccf00747a Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Fri, 23 Jan 2026 17:45:49 +0530 Subject: [PATCH 09/10] update --- .../helpers/IncomingTransactionHelper.test.ts | 86 +++++++++++-------- .../src/helpers/IncomingTransactionHelper.ts | 11 ++- .../src/utils/feature-flags.test.ts | 42 ++++----- .../src/utils/feature-flags.ts | 20 ++--- 4 files changed, 87 insertions(+), 72 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index acce54a0750..b2e51dc3a3f 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -12,7 +12,7 @@ import { TransactionStatus, TransactionType } from '../types'; import type { RemoteTransactionSource, TransactionMeta } from '../types'; import { getIncomingTransactionsPollingInterval, - isEnhancedHistoryRetrievalEnabled, + isIncomingTransactionsUseWebsocketsEnabled, } from '../utils/feature-flags'; jest.useFakeTimers(); @@ -134,6 +134,12 @@ async function runInterval( } 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(); @@ -143,7 +149,24 @@ describe('IncomingTransactionHelper', () => { .mocked(getIncomingTransactionsPollingInterval) .mockReturnValue(1000 * 30); - jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(false); + + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(true); + + 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', () => { @@ -521,28 +544,7 @@ describe('IncomingTransactionHelper', () => { }); }); - describe('transaction history retrieval when flag enhanced-history-retrieval is enabled', () => { - let subscribeMock: jest.Mock; - let unsubscribeMock: jest.Mock; - let transactionUpdatedHandler: (tx: AccountActivityTransaction) => void; - let connectionStateChangedHandler: (info: WebSocketConnectionInfo) => void; - let selectedAccountChangedHandler: () => void; - - beforeEach(() => { - jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(true); - - 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('transaction history retrieval when useWebsockets is enabled', () => { function createMessengerMock(): TransactionControllerMessenger { return { subscribe: subscribeMock, @@ -565,7 +567,7 @@ describe('IncomingTransactionHelper', () => { } describe('constructor', () => { - it('subscribes to connectionStateChanged when flag enhanced-history-retrieval is enabled', async () => { + it('subscribes to connectionStateChanged when useWebsockets is enabled', async () => { const messenger = createMessengerMock(); // eslint-disable-next-line no-new @@ -583,8 +585,10 @@ describe('IncomingTransactionHelper', () => { ); }); - it('does not subscribe to connectionStateChanged when flag enhanced-history-retrieval is disabled', async () => { - jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); + it('does not subscribe to connectionStateChanged when useWebsockets is disabled', async () => { + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(false); const messenger = createMessengerMock(); // eslint-disable-next-line no-new @@ -604,7 +608,7 @@ describe('IncomingTransactionHelper', () => { }); describe('start', () => { - it('does not start polling when flag enhanced-history-retrieval is enabled', async () => { + it('does not start polling when useWebsockets is enabled', async () => { const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, messenger: createMessengerMock(), @@ -921,8 +925,10 @@ describe('IncomingTransactionHelper', () => { }); describe('legacy polling mode', () => { - it('uses polling when flag enhanced-history-retrieval is disabled', async () => { - jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); + it('uses polling when useWebsockets is disabled', async () => { + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(false); const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, @@ -936,7 +942,9 @@ describe('IncomingTransactionHelper', () => { }); it('clears timeout on stop when polling is active', async () => { - jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(false); const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, @@ -957,7 +965,9 @@ describe('IncomingTransactionHelper', () => { }); it('handles error in initial polling gracefully', async () => { - jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(false); const remoteTransactionSource = createRemoteTransactionSourceMock([], { error: true, @@ -975,7 +985,9 @@ describe('IncomingTransactionHelper', () => { }); it('handles error in initial polling when getCurrentAccount throws', async () => { - jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(false); const getCurrentAccountMock = jest.fn().mockImplementation(() => { throw new Error('Account error'); @@ -995,7 +1007,9 @@ describe('IncomingTransactionHelper', () => { // eslint-disable-next-line jest/expect-expect it('handles error in polling interval gracefully', async () => { - jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(false); const remoteTransactionSource = createRemoteTransactionSourceMock([]); @@ -1016,7 +1030,9 @@ describe('IncomingTransactionHelper', () => { }); it('reschedules timeout after interval completes', async () => { - jest.mocked(isEnhancedHistoryRetrievalEnabled).mockReturnValue(false); + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(false); const helper = new IncomingTransactionHelper({ ...CONTROLLER_ARGS_MOCK, diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 9c4ae219692..060f26abd8e 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -14,7 +14,7 @@ import { incomingTransactionsLogger as log } from '../logger'; import type { RemoteTransactionSource, TransactionMeta } from '../types'; import { getIncomingTransactionsPollingInterval, - isEnhancedHistoryRetrievalEnabled, + isIncomingTransactionsUseWebsocketsEnabled, } from '../utils/feature-flags'; export type IncomingTransactionOptions = { @@ -67,7 +67,7 @@ export class IncomingTransactionHelper { readonly #updateTransactions?: boolean; - readonly #isEnhancedHistoryEnabled: boolean; + readonly #useWebsockets: boolean; readonly #connectionStateChangedHandler = ( connectionInfo: WebSocketConnectionInfo, @@ -121,10 +121,9 @@ export class IncomingTransactionHelper { this.#remoteTransactionSource = remoteTransactionSource; this.#trimTransactions = trimTransactions; this.#updateTransactions = updateTransactions; - this.#isEnhancedHistoryEnabled = - isEnhancedHistoryRetrievalEnabled(messenger); + this.#useWebsockets = isIncomingTransactionsUseWebsocketsEnabled(messenger); - if (this.#isEnhancedHistoryEnabled) { + if (this.#useWebsockets) { this.#messenger.subscribe( 'BackendWebSocketService:connectionStateChanged', this.#connectionStateChangedHandler, @@ -133,7 +132,7 @@ export class IncomingTransactionHelper { } start(): void { - if (this.#isRunning || this.#isEnhancedHistoryEnabled) { + if (this.#isRunning || this.#useWebsockets) { return; } diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts index ca9f333f13a..b5f147a903c 100644 --- a/packages/transaction-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-controller/src/utils/feature-flags.test.ts @@ -22,7 +22,7 @@ import { FeatureFlag, getIncomingTransactionsPollingInterval, getTimeoutAttempts, - isEnhancedHistoryRetrievalEnabled, + isIncomingTransactionsUseWebsocketsEnabled, } from './feature-flags'; import { isValidSignature } from './signature'; import type { TransactionControllerMessenger } from '..'; @@ -868,45 +868,47 @@ describe('Feature Flags Utils', () => { }); }); - describe('isEnhancedHistoryRetrievalEnabled', () => { - it('returns true when enabled is true', () => { + describe('isIncomingTransactionsUseWebsocketsEnabled', () => { + it('returns true when useWebsockets is true', () => { mockFeatureFlags({ - [FeatureFlag.EnhancedHistoryRetrieval]: { - enabled: true, + [FeatureFlag.IncomingTransactions]: { + useWebsockets: true, }, }); - expect(isEnhancedHistoryRetrievalEnabled(controllerMessenger)).toBe(true); + expect( + isIncomingTransactionsUseWebsocketsEnabled(controllerMessenger), + ).toBe(true); }); - it('returns false when enabled is false', () => { + it('returns false when useWebsockets is false', () => { mockFeatureFlags({ - [FeatureFlag.EnhancedHistoryRetrieval]: { - enabled: false, + [FeatureFlag.IncomingTransactions]: { + useWebsockets: false, }, }); - expect(isEnhancedHistoryRetrievalEnabled(controllerMessenger)).toBe( - false, - ); + expect( + isIncomingTransactionsUseWebsocketsEnabled(controllerMessenger), + ).toBe(false); }); it('returns false when flag is not present', () => { mockFeatureFlags({}); - expect(isEnhancedHistoryRetrievalEnabled(controllerMessenger)).toBe( - false, - ); + expect( + isIncomingTransactionsUseWebsocketsEnabled(controllerMessenger), + ).toBe(false); }); - it('returns false when enabled property is not present', () => { + it('returns false when useWebsockets property is not present', () => { mockFeatureFlags({ - [FeatureFlag.EnhancedHistoryRetrieval]: {}, + [FeatureFlag.IncomingTransactions]: {}, }); - expect(isEnhancedHistoryRetrievalEnabled(controllerMessenger)).toBe( - false, - ); + expect( + isIncomingTransactionsUseWebsocketsEnabled(controllerMessenger), + ).toBe(false); }); }); }); diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index d87eb432103..2dee9a7bee4 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -21,7 +21,6 @@ const DEFAULT_TRANSACTION_HISTORY_LIMIT = 40; */ export enum FeatureFlag { EIP7702 = 'confirmations_eip_7702', - EnhancedHistoryRetrieval = 'enhanced_history_retrieval', GasBuffer = 'confirmations_gas_buffer', IncomingTransactions = 'confirmations_incoming_transactions', Transactions = 'confirmations_transactions', @@ -40,12 +39,6 @@ type GasEstimateFallback = { }; export type TransactionControllerFeatureFlags = { - /** Feature flag to enable enhanced history retrieval using event-driven updates. */ - [FeatureFlag.EnhancedHistoryRetrieval]?: { - /** Whether enhanced history retrieval is enabled. */ - enabled?: boolean; - }; - /** Feature flags to support EIP-7702 / type-4 transactions. */ [FeatureFlag.EIP7702]?: { /** @@ -111,6 +104,9 @@ export type TransactionControllerFeatureFlags = { [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. */ + useWebsockets?: boolean; }; /** Miscellaneous feature flags to support the transaction controller. */ @@ -483,18 +479,20 @@ export function getTimeoutAttempts( } /** - * Checks if enhanced history retrieval is enabled. + * 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 enhanced history retrieval is enabled, false otherwise. + * @returns True if WebSocket updates are enabled, false otherwise. */ -export function isEnhancedHistoryRetrievalEnabled( +export function isIncomingTransactionsUseWebsocketsEnabled( messenger: TransactionControllerMessenger, ): boolean { const featureFlags = getFeatureFlags(messenger); - return featureFlags?.[FeatureFlag.EnhancedHistoryRetrieval]?.enabled ?? false; + return ( + featureFlags?.[FeatureFlag.IncomingTransactions]?.useWebsockets ?? false + ); } /** From 8453f3bde1febbfcc26a7b7ff7e730f4201fd262 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Fri, 23 Jan 2026 17:59:21 +0530 Subject: [PATCH 10/10] update --- .../src/helpers/IncomingTransactionHelper.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index b2e51dc3a3f..6f1c3fa6b9f 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -153,10 +153,6 @@ describe('IncomingTransactionHelper', () => { .mocked(isIncomingTransactionsUseWebsocketsEnabled) .mockReturnValue(false); - jest - .mocked(isIncomingTransactionsUseWebsocketsEnabled) - .mockReturnValue(true); - subscribeMock = jest.fn().mockImplementation((event, handler) => { if (event === 'AccountActivityService:transactionUpdated') { transactionUpdatedHandler = handler; @@ -545,6 +541,12 @@ describe('IncomingTransactionHelper', () => { }); describe('transaction history retrieval when useWebsockets is enabled', () => { + beforeEach(() => { + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(true); + }); + function createMessengerMock(): TransactionControllerMessenger { return { subscribe: subscribeMock,