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)) 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..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,6 +23,10 @@ import { convertHexToDecimal, } from '@metamask/controller-utils'; import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; +import type { + AccountActivityServiceTransactionUpdatedEvent, + BackendWebSocketServiceConnectionStateChangedEvent, +} from '@metamask/core-backend'; import EthQuery from '@metamask/eth-query'; import type { FetchGasFeeEstimateOptions, @@ -602,7 +607,11 @@ export type AllowedActions = /** * The external events available to the {@link TransactionController}. */ -export type AllowedEvents = NetworkControllerStateChangeEvent; +export type AllowedEvents = + | AccountActivityServiceTransactionUpdatedEvent + | AccountsControllerSelectedAccountChangeEvent + | BackendWebSocketServiceConnectionStateChangedEvent + | 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..6f1c3fa6b9f 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -1,3 +1,8 @@ +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'; @@ -5,7 +10,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, + isIncomingTransactionsUseWebsocketsEnabled, +} from '../utils/feature-flags'; jest.useFakeTimers(); @@ -17,7 +25,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'; @@ -123,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(); @@ -131,6 +148,21 @@ describe('IncomingTransactionHelper', () => { jest .mocked(getIncomingTransactionsPollingInterval) .mockReturnValue(1000 * 30); + + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .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', () => { @@ -507,4 +539,538 @@ describe('IncomingTransactionHelper', () => { ); }); }); + + describe('transaction history retrieval when useWebsockets is enabled', () => { + beforeEach(() => { + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .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 useWebsockets 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 useWebsockets is disabled', async () => { + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .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 useWebsockets 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), + ); + }); + }); + + 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 useWebsockets is disabled', async () => { + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .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(isIncomingTransactionsUseWebsocketsEnabled) + .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(isIncomingTransactionsUseWebsocketsEnabled) + .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(isIncomingTransactionsUseWebsocketsEnabled) + .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(isIncomingTransactionsUseWebsocketsEnabled) + .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(isIncomingTransactionsUseWebsocketsEnabled) + .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(); + }); + }); }); diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 180fc3dd034..060f26abd8e 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -1,4 +1,9 @@ import type { AccountsController } from '@metamask/accounts-controller'; +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 @@ -7,7 +12,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, + isIncomingTransactionsUseWebsocketsEnabled, +} from '../utils/feature-flags'; export type IncomingTransactionOptions = { /** Name of the client to include in requests. */ @@ -59,6 +67,24 @@ export class IncomingTransactionHelper { readonly #updateTransactions?: boolean; + readonly #useWebsockets: boolean; + + readonly #connectionStateChangedHandler = ( + connectionInfo: WebSocketConnectionInfo, + ): void => { + this.#onConnectionStateChanged(connectionInfo); + }; + + readonly #transactionUpdatedHandler = ( + transaction: AccountActivityTransaction, + ): void => { + this.#onTransactionUpdated(transaction); + }; + + readonly #selectedAccountChangedHandler = (): void => { + this.#onSelectedAccountChanged(); + }; + constructor({ client, getCurrentAccount, @@ -95,10 +121,18 @@ export class IncomingTransactionHelper { this.#remoteTransactionSource = remoteTransactionSource; this.#trimTransactions = trimTransactions; this.#updateTransactions = updateTransactions; + this.#useWebsockets = isIncomingTransactionsUseWebsocketsEnabled(messenger); + + if (this.#useWebsockets) { + this.#messenger.subscribe( + 'BackendWebSocketService:connectionStateChanged', + this.#connectionStateChangedHandler, + ); + } } start(): void { - if (this.#isRunning) { + if (this.#isRunning || this.#useWebsockets) { return; } @@ -135,6 +169,71 @@ export class IncomingTransactionHelper { 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; + } + + log('Started transaction history retrieval (event-driven)'); + + 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 { + log('Stopped transaction history retrieval'); + + 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; diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts index 3706c703ca7..b5f147a903c 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, + isIncomingTransactionsUseWebsocketsEnabled, } from './feature-flags'; import { isValidSignature } from './signature'; import type { TransactionControllerMessenger } from '..'; @@ -866,4 +867,48 @@ describe('Feature Flags Utils', () => { expect(getTimeoutAttempts(CHAIN_ID_MOCK, controllerMessenger)).toBe(0); }); }); + + describe('isIncomingTransactionsUseWebsocketsEnabled', () => { + it('returns true when useWebsockets is true', () => { + mockFeatureFlags({ + [FeatureFlag.IncomingTransactions]: { + useWebsockets: true, + }, + }); + + expect( + isIncomingTransactionsUseWebsocketsEnabled(controllerMessenger), + ).toBe(true); + }); + + it('returns false when useWebsockets is false', () => { + mockFeatureFlags({ + [FeatureFlag.IncomingTransactions]: { + useWebsockets: false, + }, + }); + + expect( + isIncomingTransactionsUseWebsocketsEnabled(controllerMessenger), + ).toBe(false); + }); + + it('returns false when flag is not present', () => { + mockFeatureFlags({}); + + expect( + isIncomingTransactionsUseWebsocketsEnabled(controllerMessenger), + ).toBe(false); + }); + + it('returns false when useWebsockets property is not present', () => { + mockFeatureFlags({ + [FeatureFlag.IncomingTransactions]: {}, + }); + + 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 a99fba5630c..2dee9a7bee4 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -104,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. */ @@ -475,6 +478,23 @@ 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 isIncomingTransactionsUseWebsocketsEnabled( + messenger: TransactionControllerMessenger, +): boolean { + const featureFlags = getFeatureFlags(messenger); + return ( + featureFlags?.[FeatureFlag.IncomingTransactions]?.useWebsockets ?? 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 508a4188962..ca8149d7e97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5132,6 +5132,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"