From a0902d50f7143db5695b669180c2f1c5839c6298 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 3 Feb 2026 19:07:29 +0530 Subject: [PATCH 01/14] Using websockets events for transaction polling --- .../src/helpers/IncomingTransactionHelper.ts | 23 +- .../src/helpers/TransactionPoller.test.ts | 263 +++++++++++++++++- .../src/helpers/TransactionPoller.ts | 71 ++++- .../src/utils/feature-flags.ts | 3 + .../src/utils/utils.test.ts | 22 ++ .../transaction-controller/src/utils/utils.ts | 21 ++ 6 files changed, 373 insertions(+), 30 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index efd419cfc5a..782556dc88f 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -1,12 +1,10 @@ import type { AccountsController } from '@metamask/accounts-controller'; -import { toHex } from '@metamask/controller-utils'; import type { Transaction as AccountActivityTransaction, WebSocketConnectionInfo, } from '@metamask/core-backend'; import { WebSocketState } from '@metamask/core-backend'; import type { Hex } from '@metamask/utils'; -import { isCaipChainId, parseCaipChainId } from '@metamask/utils'; // This package purposefully relies on Node's EventEmitter module. // eslint-disable-next-line import-x/no-nodejs-modules import EventEmitter from 'events'; @@ -15,6 +13,7 @@ import { SUPPORTED_CHAIN_IDS } from './AccountsApiRemoteTransactionSource'; import type { TransactionControllerMessenger } from '..'; import { incomingTransactionsLogger as log } from '../logger'; import type { RemoteTransactionSource, TransactionMeta } from '../types'; +import { caip2ToHex } from '../utils/utils'; import { getIncomingTransactionsPollingInterval, isIncomingTransactionsUseWebsocketsEnabled, @@ -420,24 +419,6 @@ export class IncomingTransactionHelper { return tags?.length ? tags : undefined; } - /** - * Convert CAIP-2 chain ID to hex format. - * - * @param caip2ChainId - Chain ID in CAIP-2 format (e.g., 'eip155:1') - * @returns Hex chain ID (e.g., '0x1') or undefined if invalid format - */ - #caip2ToHex(caip2ChainId: string): Hex | undefined { - if (!isCaipChainId(caip2ChainId)) { - return undefined; - } - try { - const { reference } = parseCaipChainId(caip2ChainId); - return toHex(reference); - } catch { - return undefined; - } - } - #onNetworkStatusChanged(chainIds: string[], status: 'up' | 'down'): void { if (!this.#useWebsockets) { return; @@ -446,7 +427,7 @@ export class IncomingTransactionHelper { let hasChanges = false; for (const caip2ChainId of chainIds) { - const hexChainId = this.#caip2ToHex(caip2ChainId); + const hexChainId = caip2ToHex(caip2ChainId); if (!hexChainId || !SUPPORTED_CHAIN_IDS.includes(hexChainId)) { log('Chain ID not recognized or not supported', { diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts index c7c0ae037c7..bda93c2cdd5 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts @@ -1,3 +1,4 @@ +import type { Transaction } from '@metamask/core-backend'; import type { BlockTracker } from '@metamask/network-controller'; import { TransactionPoller } from './TransactionPoller'; @@ -18,13 +19,25 @@ const BLOCK_TRACKER_MOCK = { removeListener: jest.fn(), } as unknown as jest.Mocked; -const MESSENGER_MOCK = { - call: jest.fn().mockReturnValue({ - remoteFeatureFlags: {}, - }), -} as unknown as jest.Mocked; +const createMessengerMock = (useWebsockets = true) => + ({ + call: jest.fn().mockReturnValue({ + remoteFeatureFlags: { + confirmations_transactions: { + useWebsockets, + }, + }, + }), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }) as unknown as jest.Mocked; + +let MESSENGER_MOCK: jest.Mocked; jest.mock('../utils/feature-flags', () => ({ + FeatureFlag: { + Transactions: 'confirmations_transactions', + }, getAcceleratedPollingParams: (): { countMax: number; intervalMs: number; @@ -32,7 +45,6 @@ jest.mock('../utils/feature-flags', () => ({ countMax: DEFAULT_ACCELERATED_COUNT_MAX, intervalMs: DEFAULT_ACCELERATED_POLLING_INTERVAL_MS, }), - FEATURE_FLAG_TRANSACTIONS: 'confirmations_transactions', })); /** @@ -49,6 +61,7 @@ describe('TransactionPoller', () => { beforeEach(() => { jest.resetAllMocks(); jest.clearAllTimers(); + MESSENGER_MOCK = createMessengerMock(true); }); describe('Accelerated Polling', () => { @@ -335,4 +348,242 @@ describe('TransactionPoller', () => { }, ); }); + + describe('AccountActivityService:transactionUpdated event', () => { + it('subscribes to event when started and useWebsockets is enabled', () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const listener = jest.fn(); + poller.start(listener); + + expect(MESSENGER_MOCK.subscribe).toHaveBeenCalledWith( + 'AccountActivityService:transactionUpdated', + expect.any(Function), + ); + }); + + it('does NOT subscribe to event when useWebsockets is disabled', () => { + const messengerWithoutWebsockets = createMessengerMock(false); + + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: messengerWithoutWebsockets, + chainId: CHAIN_ID_MOCK, + }); + + const listener = jest.fn(); + poller.start(listener); + + expect(messengerWithoutWebsockets.subscribe).not.toHaveBeenCalledWith( + 'AccountActivityService:transactionUpdated', + expect.any(Function), + ); + }); + + it('unsubscribes from event when stopped and useWebsockets is enabled', () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const listener = jest.fn(); + poller.start(listener); + poller.stop(); + + expect(MESSENGER_MOCK.unsubscribe).toHaveBeenCalledWith( + 'AccountActivityService:transactionUpdated', + expect.any(Function), + ); + }); + + it('does NOT unsubscribe from event when useWebsockets is disabled and stop is called', () => { + const messengerWithoutWebsockets = createMessengerMock(false); + + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: messengerWithoutWebsockets, + chainId: CHAIN_ID_MOCK, + }); + + const listener = jest.fn(); + poller.start(listener); + poller.stop(); + + expect(messengerWithoutWebsockets.unsubscribe).not.toHaveBeenCalledWith( + 'AccountActivityService:transactionUpdated', + expect.any(Function), + ); + }); + + it('triggers interval when transaction with matching chainId and confirmed status is received', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); + + const listener = jest.fn(); + poller.start(listener); + + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('triggers interval when transaction with matching chainId and finalized status is received', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); + + const listener = jest.fn(); + poller.start(listener); + + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status: 'finalized', + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does not trigger interval when transaction with non-matching chainId is received', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const listener = jest.fn(); + poller.start(listener); + + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:137', // Different chain + status: 'confirmed', + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not trigger interval when transaction with pending status is received', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const listener = jest.fn(); + poller.start(listener); + + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status: 'pending', + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not trigger interval when poller is stopped', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const listener = jest.fn(); + poller.start(listener); + + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + poller.stop(); + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index 7c9ad49baff..5d966b9ed3b 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -1,3 +1,4 @@ +import type { Transaction } from '@metamask/core-backend'; import type { BlockTracker } from '@metamask/network-controller'; import { createModuleLogger } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; @@ -6,7 +7,12 @@ import { isEqual } from 'lodash'; import { projectLogger } from '../logger'; import type { TransactionControllerMessenger } from '../TransactionController'; import type { TransactionMeta } from '../types'; -import { getAcceleratedPollingParams } from '../utils/feature-flags'; +import { caip2ToHex } from '../utils/utils'; +import { + FeatureFlag, + getAcceleratedPollingParams, + type TransactionControllerFeatureFlags, +} from '../utils/feature-flags'; const log = createModuleLogger(projectLogger, 'transaction-poller'); @@ -34,6 +40,8 @@ export class TransactionPoller { #timeout?: NodeJS.Timeout; + #useWebsockets: boolean; + constructor({ blockTracker, chainId, @@ -46,6 +54,13 @@ export class TransactionPoller { this.#blockTracker = blockTracker; this.#chainId = chainId; this.#messenger = messenger; + + const featureFlags = messenger.call( + 'RemoteFeatureFlagController:getState', + ).remoteFeatureFlags as TransactionControllerFeatureFlags; + + this.#useWebsockets = + featureFlags?.[FeatureFlag.Transactions]?.useWebsockets ?? false; } /** @@ -61,6 +76,8 @@ export class TransactionPoller { this.#listener = listener; this.#running = true; + this.#subscribeToTransactionUpdates(); + this.#queue(); log('Started'); @@ -82,6 +99,7 @@ export class TransactionPoller { this.#stopTimeout(); this.#stopBlockTracker(); + this.#unsubscribeFromTransactionUpdates(); log('Stopped'); } @@ -155,8 +173,11 @@ export class TransactionPoller { async #interval( isAccelerated: boolean, latestBlockNumber?: string, + transactionUpdateReceived: boolean = false, ): Promise { - if (isAccelerated) { + if (transactionUpdateReceived) { + log('AccountActivityService:transactionUpdated received'); + } else if (isAccelerated) { log('Accelerated interval', this.#acceleratedCount + 1); } else { log('Block tracker interval', latestBlockNumber); @@ -167,7 +188,7 @@ export class TransactionPoller { await this.#listener?.(latestBlockNumberFinal); - if (isAccelerated && this.#running) { + if (isAccelerated && this.#running && !transactionUpdateReceived) { this.#acceleratedCount += 1; } } @@ -189,4 +210,48 @@ export class TransactionPoller { this.#blockTracker.removeListener('latest', this.#blockTrackerListener); this.#blockTrackerListener = undefined; } + + #transactionUpdatedHandler = (transaction: Transaction): void => { + if (!this.#running) { + return; + } + + const hexChainId = caip2ToHex(transaction.chain); + if (hexChainId !== this.#chainId) { + return; + } + + if ( + transaction.status !== 'confirmed' && + transaction.status !== 'finalized' + ) { + return; + } + + this.#interval(true, undefined, true).catch(() => { + // Silently catch errors to prevent unhandled rejections + }); + }; + + #subscribeToTransactionUpdates(): void { + if (!this.#useWebsockets) { + return; + } + + this.#messenger.subscribe( + 'AccountActivityService:transactionUpdated', + this.#transactionUpdatedHandler, + ); + } + + #unsubscribeFromTransactionUpdates(): void { + if (!this.#useWebsockets) { + return; + } + + this.#messenger.unsubscribe( + 'AccountActivityService:transactionUpdated', + this.#transactionUpdatedHandler, + ); + } } diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index 2dee9a7bee4..dcdb1f92b03 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -189,6 +189,9 @@ export type TransactionControllerFeatureFlags = { */ default?: number; }; + + /** Whether to use WebSocket for event-driven transaction updates instead of polling. */ + useWebsockets?: boolean; }; }; diff --git a/packages/transaction-controller/src/utils/utils.test.ts b/packages/transaction-controller/src/utils/utils.test.ts index a947f350664..6d28ddc7a01 100644 --- a/packages/transaction-controller/src/utils/utils.test.ts +++ b/packages/transaction-controller/src/utils/utils.test.ts @@ -356,4 +356,26 @@ describe('utils', () => { ); }); }); + + describe('caip2ToHex', () => { + it('converts eip155:1 to 0x1', () => { + expect(util.caip2ToHex('eip155:1')).toBe('0x1'); + }); + + it('converts eip155:137 to 0x89', () => { + expect(util.caip2ToHex('eip155:137')).toBe('0x89'); + }); + + it('converts eip155:8453 to 0x2105', () => { + expect(util.caip2ToHex('eip155:8453')).toBe('0x2105'); + }); + + it('returns undefined for invalid format', () => { + expect(util.caip2ToHex('invalid')).toBeUndefined(); + }); + + it('returns undefined for malformed CAIP-2 format', () => { + expect(util.caip2ToHex('not:valid:format')).toBeUndefined(); + }); + }); }); diff --git a/packages/transaction-controller/src/utils/utils.ts b/packages/transaction-controller/src/utils/utils.ts index ac9cbff6c34..65ee5ca0567 100644 --- a/packages/transaction-controller/src/utils/utils.ts +++ b/packages/transaction-controller/src/utils/utils.ts @@ -1,9 +1,12 @@ import type { AccessList, AuthorizationList } from '@ethereumjs/common'; +import { toHex } from '@metamask/controller-utils'; import type { Hex, Json } from '@metamask/utils'; import { add0x, getKnownPropertyNames, + isCaipChainId, isStrictHexString, + parseCaipChainId, } from '@metamask/utils'; import BN from 'bn.js'; @@ -298,3 +301,21 @@ export function setEnvelopeType( : TransactionEnvelopeType.legacy; } } + +/** + * Convert CAIP-2 chain ID to hex format. + * + * @param caip2ChainId - Chain ID in CAIP-2 format (e.g., 'eip155:1') + * @returns Hex chain ID (e.g., '0x1') or undefined if invalid format + */ +export function caip2ToHex(caip2ChainId: string): Hex | undefined { + if (!isCaipChainId(caip2ChainId)) { + return undefined; + } + try { + const { reference } = parseCaipChainId(caip2ChainId); + return toHex(reference); + } catch { + return undefined; + } +} From fd500d7ffda1d64845c84a4a835f2b08d0725fd7 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 3 Feb 2026 20:47:49 +0530 Subject: [PATCH 02/14] update --- .../src/helpers/TransactionPoller.test.ts | 34 ------------------- .../src/helpers/TransactionPoller.ts | 7 ++-- 2 files changed, 2 insertions(+), 39 deletions(-) diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts index bda93c2cdd5..c0ced6160a0 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts @@ -454,40 +454,6 @@ describe('TransactionPoller', () => { expect(listener).toHaveBeenCalledTimes(1); }); - it('triggers interval when transaction with matching chainId and finalized status is received', async () => { - const poller = new TransactionPoller({ - blockTracker: BLOCK_TRACKER_MOCK, - messenger: MESSENGER_MOCK, - chainId: CHAIN_ID_MOCK, - }); - - BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); - - const listener = jest.fn(); - poller.start(listener); - - const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( - (call) => call[0] === 'AccountActivityService:transactionUpdated', - ); - const eventHandler = subscribeCall?.[1] as ( - transaction: Transaction, - ) => void; - - const transaction: Transaction = { - id: '0xabc', - chain: 'eip155:1', - status: 'finalized', - timestamp: Date.now(), - from: '0x123', - to: '0x456', - }; - - eventHandler(transaction); - await flushPromises(); - - expect(listener).toHaveBeenCalledTimes(1); - }); - it('does not trigger interval when transaction with non-matching chainId is received', async () => { const poller = new TransactionPoller({ blockTracker: BLOCK_TRACKER_MOCK, diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index 5d966b9ed3b..fa5f587996f 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -221,14 +221,11 @@ export class TransactionPoller { return; } - if ( - transaction.status !== 'confirmed' && - transaction.status !== 'finalized' - ) { + if (transaction.status !== 'confirmed') { return; } - this.#interval(true, undefined, true).catch(() => { + this.#interval(false, undefined, true).catch(() => { // Silently catch errors to prevent unhandled rejections }); }; From 41a05675d9b3aebf28ee89db142765acd7b5c9fb Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 3 Feb 2026 20:52:29 +0530 Subject: [PATCH 03/14] update --- packages/transaction-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f6cb8649c98..7507da118f3 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add event-driven transaction polling via `AccountActivityService:transactionUpdated` ([#7822](https://github.com/MetaMask/core/pull/7822)) + ## [62.13.0] ### Added From 45493b26fa7295a5b6898ad611586644140f92dc Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 3 Feb 2026 21:29:36 +0530 Subject: [PATCH 04/14] update --- .../src/helpers/IncomingTransactionHelper.ts | 2 +- .../src/helpers/TransactionPoller.test.ts | 4 +++- .../src/helpers/TransactionPoller.ts | 13 ++++++------- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 782556dc88f..08529c5bbfd 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -13,11 +13,11 @@ import { SUPPORTED_CHAIN_IDS } from './AccountsApiRemoteTransactionSource'; import type { TransactionControllerMessenger } from '..'; import { incomingTransactionsLogger as log } from '../logger'; import type { RemoteTransactionSource, TransactionMeta } from '../types'; -import { caip2ToHex } from '../utils/utils'; import { getIncomingTransactionsPollingInterval, isIncomingTransactionsUseWebsocketsEnabled, } from '../utils/feature-flags'; +import { caip2ToHex } from '../utils/utils'; export type IncomingTransactionOptions = { /** Name of the client to include in requests. */ diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts index c0ced6160a0..8d5a0742839 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts @@ -19,7 +19,9 @@ const BLOCK_TRACKER_MOCK = { removeListener: jest.fn(), } as unknown as jest.Mocked; -const createMessengerMock = (useWebsockets = true) => +const createMessengerMock = ( + useWebsockets = true, +): jest.Mocked => ({ call: jest.fn().mockReturnValue({ remoteFeatureFlags: { diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index fa5f587996f..21811b31aaf 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -7,12 +7,12 @@ import { isEqual } from 'lodash'; import { projectLogger } from '../logger'; import type { TransactionControllerMessenger } from '../TransactionController'; import type { TransactionMeta } from '../types'; -import { caip2ToHex } from '../utils/utils'; import { FeatureFlag, getAcceleratedPollingParams, - type TransactionControllerFeatureFlags, } from '../utils/feature-flags'; +import type { TransactionControllerFeatureFlags } from '../utils/feature-flags'; +import { caip2ToHex } from '../utils/utils'; const log = createModuleLogger(projectLogger, 'transaction-poller'); @@ -40,7 +40,7 @@ export class TransactionPoller { #timeout?: NodeJS.Timeout; - #useWebsockets: boolean; + readonly #useWebsockets: boolean; constructor({ blockTracker, @@ -55,9 +55,8 @@ export class TransactionPoller { this.#chainId = chainId; this.#messenger = messenger; - const featureFlags = messenger.call( - 'RemoteFeatureFlagController:getState', - ).remoteFeatureFlags as TransactionControllerFeatureFlags; + const featureFlags = messenger.call('RemoteFeatureFlagController:getState') + .remoteFeatureFlags as TransactionControllerFeatureFlags; this.#useWebsockets = featureFlags?.[FeatureFlag.Transactions]?.useWebsockets ?? false; @@ -211,7 +210,7 @@ export class TransactionPoller { this.#blockTrackerListener = undefined; } - #transactionUpdatedHandler = (transaction: Transaction): void => { + readonly #transactionUpdatedHandler = (transaction: Transaction): void => { if (!this.#running) { return; } From 65c019c8d23b4813e1e01b14eeb2f65849c0fdb4 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 3 Feb 2026 21:33:03 +0530 Subject: [PATCH 05/14] update --- packages/transaction-controller/CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index c2165a29478..c3a9b873270 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,15 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -<<<<<<< transaction_polling_improvements ### Added - Add event-driven transaction polling via `AccountActivityService:transactionUpdated` ([#7822](https://github.com/MetaMask/core/pull/7822)) -======= + ### Changed - Bump `@metamask/core-backend` from `^5.0.0` to `^5.1.0` ([#7817](https://github.com/MetaMask/core/pull/7817)) ->>>>>>> main ## [62.13.0] From 9c2c04ceeef23e057f0eec86e87778df0e5b4922 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 4 Feb 2026 19:11:07 +0530 Subject: [PATCH 06/14] check from account of transaction received --- .../src/helpers/TransactionPoller.test.ts | 85 +++++++++++++++++-- .../src/helpers/TransactionPoller.ts | 9 ++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts index 8d5a0742839..15d301da60b 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts @@ -19,16 +19,25 @@ const BLOCK_TRACKER_MOCK = { removeListener: jest.fn(), } as unknown as jest.Mocked; +const SELECTED_ACCOUNT_MOCK = { + address: '0x123', +}; + const createMessengerMock = ( useWebsockets = true, ): jest.Mocked => ({ - call: jest.fn().mockReturnValue({ - remoteFeatureFlags: { - confirmations_transactions: { - useWebsockets, + call: jest.fn().mockImplementation((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return SELECTED_ACCOUNT_MOCK; + } + return { + remoteFeatureFlags: { + confirmations_transactions: { + useWebsockets, + }, }, - }, + }; }), subscribe: jest.fn(), unsubscribe: jest.fn(), @@ -520,6 +529,72 @@ describe('TransactionPoller', () => { expect(listener).not.toHaveBeenCalled(); }); + it('does not trigger interval when transaction is from a different address than the selected account', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const listener = jest.fn(); + poller.start(listener); + + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0x999', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('triggers interval when transaction from address matches selected account (case-insensitive)', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); + + const listener = jest.fn(); + poller.start(listener); + + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0X123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + it('does not trigger interval when poller is stopped', async () => { const poller = new TransactionPoller({ blockTracker: BLOCK_TRACKER_MOCK, diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index 21811b31aaf..5b387f57a96 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -224,6 +224,15 @@ export class TransactionPoller { return; } + const selectedAccount = this.#messenger.call( + 'AccountsController:getSelectedAccount', + ); + if ( + selectedAccount.address.toLowerCase() !== transaction.from.toLowerCase() + ) { + return; + } + this.#interval(false, undefined, true).catch(() => { // Silently catch errors to prevent unhandled rejections }); From dfcef63ec6289d69478c3c099112290f575aa293 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 4 Feb 2026 19:17:58 +0530 Subject: [PATCH 07/14] remove use of feature flag --- .../src/helpers/TransactionPoller.test.ts | 58 ++----------------- .../src/helpers/TransactionPoller.ts | 22 +------ 2 files changed, 6 insertions(+), 74 deletions(-) diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts index 15d301da60b..0109672efbc 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts @@ -23,21 +23,13 @@ const SELECTED_ACCOUNT_MOCK = { address: '0x123', }; -const createMessengerMock = ( - useWebsockets = true, -): jest.Mocked => +const createMessengerMock = (): jest.Mocked => ({ call: jest.fn().mockImplementation((action: string) => { if (action === 'AccountsController:getSelectedAccount') { return SELECTED_ACCOUNT_MOCK; } - return { - remoteFeatureFlags: { - confirmations_transactions: { - useWebsockets, - }, - }, - }; + return {}; }), subscribe: jest.fn(), unsubscribe: jest.fn(), @@ -46,9 +38,6 @@ const createMessengerMock = ( let MESSENGER_MOCK: jest.Mocked; jest.mock('../utils/feature-flags', () => ({ - FeatureFlag: { - Transactions: 'confirmations_transactions', - }, getAcceleratedPollingParams: (): { countMax: number; intervalMs: number; @@ -72,7 +61,7 @@ describe('TransactionPoller', () => { beforeEach(() => { jest.resetAllMocks(); jest.clearAllTimers(); - MESSENGER_MOCK = createMessengerMock(true); + MESSENGER_MOCK = createMessengerMock(); }); describe('Accelerated Polling', () => { @@ -361,7 +350,7 @@ describe('TransactionPoller', () => { }); describe('AccountActivityService:transactionUpdated event', () => { - it('subscribes to event when started and useWebsockets is enabled', () => { + it('subscribes to event when started', () => { const poller = new TransactionPoller({ blockTracker: BLOCK_TRACKER_MOCK, messenger: MESSENGER_MOCK, @@ -377,25 +366,7 @@ describe('TransactionPoller', () => { ); }); - it('does NOT subscribe to event when useWebsockets is disabled', () => { - const messengerWithoutWebsockets = createMessengerMock(false); - - const poller = new TransactionPoller({ - blockTracker: BLOCK_TRACKER_MOCK, - messenger: messengerWithoutWebsockets, - chainId: CHAIN_ID_MOCK, - }); - - const listener = jest.fn(); - poller.start(listener); - - expect(messengerWithoutWebsockets.subscribe).not.toHaveBeenCalledWith( - 'AccountActivityService:transactionUpdated', - expect.any(Function), - ); - }); - - it('unsubscribes from event when stopped and useWebsockets is enabled', () => { + it('unsubscribes from event when stopped', () => { const poller = new TransactionPoller({ blockTracker: BLOCK_TRACKER_MOCK, messenger: MESSENGER_MOCK, @@ -412,25 +383,6 @@ describe('TransactionPoller', () => { ); }); - it('does NOT unsubscribe from event when useWebsockets is disabled and stop is called', () => { - const messengerWithoutWebsockets = createMessengerMock(false); - - const poller = new TransactionPoller({ - blockTracker: BLOCK_TRACKER_MOCK, - messenger: messengerWithoutWebsockets, - chainId: CHAIN_ID_MOCK, - }); - - const listener = jest.fn(); - poller.start(listener); - poller.stop(); - - expect(messengerWithoutWebsockets.unsubscribe).not.toHaveBeenCalledWith( - 'AccountActivityService:transactionUpdated', - expect.any(Function), - ); - }); - it('triggers interval when transaction with matching chainId and confirmed status is received', async () => { const poller = new TransactionPoller({ blockTracker: BLOCK_TRACKER_MOCK, diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index 5b387f57a96..0b4d1849e02 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -7,11 +7,7 @@ import { isEqual } from 'lodash'; import { projectLogger } from '../logger'; import type { TransactionControllerMessenger } from '../TransactionController'; import type { TransactionMeta } from '../types'; -import { - FeatureFlag, - getAcceleratedPollingParams, -} from '../utils/feature-flags'; -import type { TransactionControllerFeatureFlags } from '../utils/feature-flags'; +import { getAcceleratedPollingParams } from '../utils/feature-flags'; import { caip2ToHex } from '../utils/utils'; const log = createModuleLogger(projectLogger, 'transaction-poller'); @@ -40,8 +36,6 @@ export class TransactionPoller { #timeout?: NodeJS.Timeout; - readonly #useWebsockets: boolean; - constructor({ blockTracker, chainId, @@ -54,12 +48,6 @@ export class TransactionPoller { this.#blockTracker = blockTracker; this.#chainId = chainId; this.#messenger = messenger; - - const featureFlags = messenger.call('RemoteFeatureFlagController:getState') - .remoteFeatureFlags as TransactionControllerFeatureFlags; - - this.#useWebsockets = - featureFlags?.[FeatureFlag.Transactions]?.useWebsockets ?? false; } /** @@ -239,10 +227,6 @@ export class TransactionPoller { }; #subscribeToTransactionUpdates(): void { - if (!this.#useWebsockets) { - return; - } - this.#messenger.subscribe( 'AccountActivityService:transactionUpdated', this.#transactionUpdatedHandler, @@ -250,10 +234,6 @@ export class TransactionPoller { } #unsubscribeFromTransactionUpdates(): void { - if (!this.#useWebsockets) { - return; - } - this.#messenger.unsubscribe( 'AccountActivityService:transactionUpdated', this.#transactionUpdatedHandler, From dc0d25520bf24d4d77128d2a30cabf5fdc9afaae Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 4 Feb 2026 19:24:19 +0530 Subject: [PATCH 08/14] cleanup --- .../src/helpers/TransactionPoller.ts | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index 0b4d1849e02..be3c40f6100 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -12,6 +12,12 @@ import { caip2ToHex } from '../utils/utils'; const log = createModuleLogger(projectLogger, 'transaction-poller'); +enum IntervalTrigger { + Accelerated = 'Accelerated', + BlockTracker = 'BlockTracker', + TransactionUpdate = 'TransactionUpdate', +} + /** * Helper class to orchestrate when to poll pending transactions. * Initially starts polling via a timeout chain every 2 seconds up to 5 times. @@ -139,7 +145,7 @@ export class TransactionPoller { if (this.#acceleratedCount >= countMax) { // eslint-disable-next-line @typescript-eslint/no-misused-promises this.#blockTrackerListener = (latestBlockNumber): Promise => - this.#interval(false, latestBlockNumber); + this.#interval(IntervalTrigger.BlockTracker, latestBlockNumber); this.#blockTracker.on('latest', this.#blockTrackerListener); @@ -152,22 +158,27 @@ export class TransactionPoller { // eslint-disable-next-line @typescript-eslint/no-misused-promises this.#timeout = setTimeout(async () => { - await this.#interval(true); + await this.#interval(IntervalTrigger.Accelerated); this.#queue(); }, intervalMs); } async #interval( - isAccelerated: boolean, + trigger: IntervalTrigger, latestBlockNumber?: string, - transactionUpdateReceived: boolean = false, ): Promise { - if (transactionUpdateReceived) { - log('AccountActivityService:transactionUpdated received'); - } else if (isAccelerated) { - log('Accelerated interval', this.#acceleratedCount + 1); - } else { - log('Block tracker interval', latestBlockNumber); + switch (trigger) { + case IntervalTrigger.TransactionUpdate: + log('AccountActivityService:transactionUpdated received'); + break; + case IntervalTrigger.Accelerated: + log('Accelerated interval', this.#acceleratedCount + 1); + break; + case IntervalTrigger.BlockTracker: + log('Block tracker interval', latestBlockNumber); + break; + default: + break; } const latestBlockNumberFinal = @@ -175,7 +186,7 @@ export class TransactionPoller { await this.#listener?.(latestBlockNumberFinal); - if (isAccelerated && this.#running && !transactionUpdateReceived) { + if (trigger === IntervalTrigger.Accelerated && this.#running) { this.#acceleratedCount += 1; } } @@ -221,7 +232,7 @@ export class TransactionPoller { return; } - this.#interval(false, undefined, true).catch(() => { + this.#interval(IntervalTrigger.TransactionUpdate).catch(() => { // Silently catch errors to prevent unhandled rejections }); }; From 32fe598bdeccd3d1795be05e3ba4ce7ccaa6cb68 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 4 Feb 2026 19:29:16 +0530 Subject: [PATCH 09/14] update --- .../src/helpers/TransactionPoller.test.ts | 61 ++++++++++--------- .../src/helpers/TransactionPoller.ts | 3 +- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts index 0109672efbc..31e6bbd80ce 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts @@ -383,39 +383,42 @@ describe('TransactionPoller', () => { ); }); - it('triggers interval when transaction with matching chainId and confirmed status is received', async () => { - const poller = new TransactionPoller({ - blockTracker: BLOCK_TRACKER_MOCK, - messenger: MESSENGER_MOCK, - chainId: CHAIN_ID_MOCK, - }); - - BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); + it.each(['confirmed', 'dropped', 'failed'])( + 'triggers interval when transaction with %s status is received', + async (status) => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); - const listener = jest.fn(); - poller.start(listener); + BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); - const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( - (call) => call[0] === 'AccountActivityService:transactionUpdated', - ); - const eventHandler = subscribeCall?.[1] as ( - transaction: Transaction, - ) => void; - - const transaction: Transaction = { - id: '0xabc', - chain: 'eip155:1', - status: 'confirmed', - timestamp: Date.now(), - from: '0x123', - to: '0x456', - }; + const listener = jest.fn(); + poller.start(listener); - eventHandler(transaction); - await flushPromises(); + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status, + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); - expect(listener).toHaveBeenCalledTimes(1); - }); + expect(listener).toHaveBeenCalledTimes(1); + }, + ); it('does not trigger interval when transaction with non-matching chainId is received', async () => { const poller = new TransactionPoller({ diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index be3c40f6100..6e308d2e437 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -219,7 +219,8 @@ export class TransactionPoller { return; } - if (transaction.status !== 'confirmed') { + const finalStatuses = ['confirmed', 'dropped', 'failed']; + if (!finalStatuses.includes(transaction.status)) { return; } From 712814d52cc46c133ad9f06be1f1afffec0cf793 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 4 Feb 2026 19:30:11 +0530 Subject: [PATCH 10/14] update --- packages/transaction-controller/src/utils/feature-flags.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index dcdb1f92b03..c9003341cb3 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -189,9 +189,6 @@ export type TransactionControllerFeatureFlags = { */ default?: number; }; - - /** Whether to use WebSocket for event-driven transaction updates instead of polling. */ - useWebsockets?: boolean; }; }; @@ -227,7 +224,7 @@ export function getEIP7702ContractAddresses( const contracts = featureFlags?.[FeatureFlag.EIP7702]?.contracts?.[ - chainId.toLowerCase() as Hex + chainId.toLowerCase() as Hex ] ?? []; return contracts From d175fb2466e694643802db9173cb723fe06d0836 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 4 Feb 2026 20:01:15 +0530 Subject: [PATCH 11/14] update --- packages/transaction-controller/src/utils/feature-flags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index c9003341cb3..2dee9a7bee4 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -224,7 +224,7 @@ export function getEIP7702ContractAddresses( const contracts = featureFlags?.[FeatureFlag.EIP7702]?.contracts?.[ - chainId.toLowerCase() as Hex + chainId.toLowerCase() as Hex ] ?? []; return contracts From 7db2f62ea27e71f573f9375b9aafa31979f408a0 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 4 Feb 2026 20:34:10 +0530 Subject: [PATCH 12/14] update --- .../src/helpers/TransactionPoller.test.ts | 61 +++++++++---------- .../src/helpers/TransactionPoller.ts | 3 +- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts index 31e6bbd80ce..0109672efbc 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts @@ -383,42 +383,39 @@ describe('TransactionPoller', () => { ); }); - it.each(['confirmed', 'dropped', 'failed'])( - 'triggers interval when transaction with %s status is received', - async (status) => { - const poller = new TransactionPoller({ - blockTracker: BLOCK_TRACKER_MOCK, - messenger: MESSENGER_MOCK, - chainId: CHAIN_ID_MOCK, - }); + it('triggers interval when transaction with matching chainId and confirmed status is received', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); - BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); + BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); - const listener = jest.fn(); - poller.start(listener); + const listener = jest.fn(); + poller.start(listener); - const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( - (call) => call[0] === 'AccountActivityService:transactionUpdated', - ); - const eventHandler = subscribeCall?.[1] as ( - transaction: Transaction, - ) => void; - - const transaction: Transaction = { - id: '0xabc', - chain: 'eip155:1', - status, - timestamp: Date.now(), - from: '0x123', - to: '0x456', - }; - - eventHandler(transaction); - await flushPromises(); + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; - expect(listener).toHaveBeenCalledTimes(1); - }, - ); + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(1); + }); it('does not trigger interval when transaction with non-matching chainId is received', async () => { const poller = new TransactionPoller({ diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index 6e308d2e437..be3c40f6100 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -219,8 +219,7 @@ export class TransactionPoller { return; } - const finalStatuses = ['confirmed', 'dropped', 'failed']; - if (!finalStatuses.includes(transaction.status)) { + if (transaction.status !== 'confirmed') { return; } From fc655f8a2fd2f1db51b1acda39ae3dac2108e3b7 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Fri, 6 Feb 2026 10:01:38 +0530 Subject: [PATCH 13/14] update --- .../src/helpers/TransactionPoller.test.ts | 61 ++++++++++--------- .../src/helpers/TransactionPoller.ts | 12 ++-- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts index 0109672efbc..87c569f39f6 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts @@ -383,39 +383,42 @@ describe('TransactionPoller', () => { ); }); - it('triggers interval when transaction with matching chainId and confirmed status is received', async () => { - const poller = new TransactionPoller({ - blockTracker: BLOCK_TRACKER_MOCK, - messenger: MESSENGER_MOCK, - chainId: CHAIN_ID_MOCK, - }); - - BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); + it.each(['confirmed', 'dropped', 'failed'] as const)( + 'triggers interval when transaction with matching chainId and %s status is received', + async (status) => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); - const listener = jest.fn(); - poller.start(listener); + BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); - const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( - (call) => call[0] === 'AccountActivityService:transactionUpdated', - ); - const eventHandler = subscribeCall?.[1] as ( - transaction: Transaction, - ) => void; - - const transaction: Transaction = { - id: '0xabc', - chain: 'eip155:1', - status: 'confirmed', - timestamp: Date.now(), - from: '0x123', - to: '0x456', - }; + const listener = jest.fn(); + poller.start(listener); - eventHandler(transaction); - await flushPromises(); + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status, + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); - expect(listener).toHaveBeenCalledTimes(1); - }); + expect(listener).toHaveBeenCalledTimes(1); + }, + ); it('does not trigger interval when transaction with non-matching chainId is received', async () => { const poller = new TransactionPoller({ diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index be3c40f6100..7632070cc86 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -15,7 +15,7 @@ const log = createModuleLogger(projectLogger, 'transaction-poller'); enum IntervalTrigger { Accelerated = 'Accelerated', BlockTracker = 'BlockTracker', - TransactionUpdate = 'TransactionUpdate', + Websocket = 'Websocket', } /** @@ -168,7 +168,7 @@ export class TransactionPoller { latestBlockNumber?: string, ): Promise { switch (trigger) { - case IntervalTrigger.TransactionUpdate: + case IntervalTrigger.Websocket: log('AccountActivityService:transactionUpdated received'); break; case IntervalTrigger.Accelerated: @@ -219,7 +219,11 @@ export class TransactionPoller { return; } - if (transaction.status !== 'confirmed') { + if ( + transaction.status !== 'confirmed' && + transaction.status !== 'dropped' && + transaction.status !== 'failed' + ) { return; } @@ -232,7 +236,7 @@ export class TransactionPoller { return; } - this.#interval(IntervalTrigger.TransactionUpdate).catch(() => { + this.#interval(IntervalTrigger.Websocket).catch(() => { // Silently catch errors to prevent unhandled rejections }); }; From ac9ae086d142953f5d9779e81c204351281d0d08 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Fri, 6 Feb 2026 13:57:26 +0530 Subject: [PATCH 14/14] update --- .../src/helpers/TransactionPoller.test.ts | 120 +++++++++++++++++- .../src/helpers/TransactionPoller.ts | 7 + 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts index 87c569f39f6..f9a5fb25cbf 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.test.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.test.ts @@ -51,10 +51,11 @@ jest.mock('../utils/feature-flags', () => ({ * Creates a mock transaction metadata object. * * @param id - The transaction ID. + * @param hash - The transaction hash. * @returns The mock transaction metadata object. */ -function createTransactionMetaMock(id: string): TransactionMeta { - return { id } as TransactionMeta; +function createTransactionMetaMock(id: string, hash?: string): TransactionMeta { + return { id, hash } as TransactionMeta; } describe('TransactionPoller', () => { @@ -394,6 +395,10 @@ describe('TransactionPoller', () => { BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); + poller.setPendingTransactions([ + createTransactionMetaMock('tx1', '0xabc'), + ]); + const listener = jest.fn(); poller.start(listener); @@ -525,6 +530,10 @@ describe('TransactionPoller', () => { BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); + poller.setPendingTransactions([ + createTransactionMetaMock('tx1', '0xabc'), + ]); + const listener = jest.fn(); poller.start(listener); @@ -583,5 +592,112 @@ describe('TransactionPoller', () => { expect(listener).not.toHaveBeenCalled(); }); + + it('does not trigger interval when transaction id does not match any pending transaction hash', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + poller.setPendingTransactions([ + createTransactionMetaMock('tx1', '0xdef'), + createTransactionMetaMock('tx2', '0xghi'), + ]); + + const listener = jest.fn(); + poller.start(listener); + + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not trigger interval when no pending transactions are set', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const listener = jest.fn(); + poller.start(listener); + + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('triggers interval when transaction id matches pending transaction hash (case-insensitive)', async () => { + const poller = new TransactionPoller({ + blockTracker: BLOCK_TRACKER_MOCK, + messenger: MESSENGER_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + BLOCK_TRACKER_MOCK.getLatestBlock.mockResolvedValue(BLOCK_NUMBER_MOCK); + + poller.setPendingTransactions([ + createTransactionMetaMock('tx1', '0xABC'), + ]); + + const listener = jest.fn(); + poller.start(listener); + + const subscribeCall = MESSENGER_MOCK.subscribe.mock.calls.find( + (call) => call[0] === 'AccountActivityService:transactionUpdated', + ); + const eventHandler = subscribeCall?.[1] as ( + transaction: Transaction, + ) => void; + + const transaction: Transaction = { + id: '0xabc', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0x123', + to: '0x456', + }; + + eventHandler(transaction); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/packages/transaction-controller/src/helpers/TransactionPoller.ts b/packages/transaction-controller/src/helpers/TransactionPoller.ts index 7632070cc86..167030441b2 100644 --- a/packages/transaction-controller/src/helpers/TransactionPoller.ts +++ b/packages/transaction-controller/src/helpers/TransactionPoller.ts @@ -236,6 +236,13 @@ export class TransactionPoller { return; } + const isPendingTransaction = this.#pendingTransactions?.some( + (tx) => tx.hash?.toLowerCase() === transaction.id.toLowerCase(), + ); + if (!isPendingTransaction) { + return; + } + this.#interval(IntervalTrigger.Websocket).catch(() => { // Silently catch errors to prevent unhandled rejections });