From 3333fef7c2e379a5eead51a0798fcf64af9ed17b Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 28 Jan 2026 13:48:40 +0530 Subject: [PATCH 01/10] feat(transaction-controller): add network fallback mechanism to IncomingTransactionHelper --- .../src/TransactionController.ts | 2 + .../AccountsApiRemoteTransactionSource.ts | 4 +- .../helpers/IncomingTransactionHelper.test.ts | 252 +++++++++++++++++- .../src/helpers/IncomingTransactionHelper.ts | 107 +++++++- packages/transaction-controller/src/types.ts | 6 + 5 files changed, 363 insertions(+), 8 deletions(-) diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 09b42639521..fa7499f5a9a 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -24,6 +24,7 @@ import { } from '@metamask/controller-utils'; import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; import type { + AccountActivityServiceStatusChangedEvent, AccountActivityServiceTransactionUpdatedEvent, BackendWebSocketServiceConnectionStateChangedEvent, } from '@metamask/core-backend'; @@ -608,6 +609,7 @@ export type AllowedActions = * The external events available to the {@link TransactionController}. */ export type AllowedEvents = + | AccountActivityServiceStatusChangedEvent | AccountActivityServiceTransactionUpdatedEvent | AccountsControllerSelectedAccountChangeEvent | BackendWebSocketServiceConnectionStateChangedEvent diff --git a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts b/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts index cd2bb3bac8e..185ec3a9fed 100644 --- a/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts +++ b/packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts @@ -50,11 +50,11 @@ export class AccountsApiRemoteTransactionSource async fetchTransactions( request: RemoteTransactionSourceRequest, ): Promise { - const { address } = request; + const { address, chainIds } = request; const responseTransactions = await this.#queryTransactions( request, - SUPPORTED_CHAIN_IDS, + chainIds ?? SUPPORTED_CHAIN_IDS, ); log( diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index 6f1c3fa6b9f..47c7257d2a0 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -5,6 +5,7 @@ import type { import { WebSocketState } from '@metamask/core-backend'; import type { Hex } from '@metamask/utils'; +import { SUPPORTED_CHAIN_IDS } from './AccountsApiRemoteTransactionSource'; import { IncomingTransactionHelper } from './IncomingTransactionHelper'; import type { TransactionControllerMessenger } from '..'; import { flushPromises } from '../../../../tests/helpers'; @@ -22,7 +23,6 @@ jest.mock('../utils/feature-flags'); // eslint-disable-next-line jest/prefer-spy-on 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 = { @@ -85,7 +85,7 @@ const createRemoteTransactionSourceMock = ( error?: boolean; } = {}, ): RemoteTransactionSource => ({ - getSupportedChains: jest.fn(() => chainIds ?? [CHAIN_ID_MOCK]), + getSupportedChains: jest.fn(() => chainIds ?? SUPPORTED_CHAIN_IDS), fetchTransactions: jest.fn(() => error ? Promise.reject(new Error('Test Error')) @@ -133,6 +133,37 @@ async function runInterval( }; } +const MAINNET_CAIP2 = 'eip155:1'; +const POLYGON_CAIP2 = 'eip155:137'; +const UNSUPPORTED_CAIP2 = 'eip155:2457'; + +// Helper to convert hex chain ID to CAIP-2 format +const hexToCaip2 = (hexChainId: string): string => { + const decimal = parseInt(hexChainId, 16); + return `eip155:${decimal}`; +}; + +// Convert all supported hex chain IDs to CAIP-2 format for testing +const SUPPORTED_CAIP2_CHAINS = SUPPORTED_CHAIN_IDS.map(hexToCaip2); + +let statusChangedHandler: (event: { + chainIds: string[]; + status: 'up' | 'down'; +}) => void; + +function createMessengerMockWithStatusChanged(): TransactionControllerMessenger { + const localSubscribeMock = jest.fn().mockImplementation((event, handler) => { + if (event === 'AccountActivityService:statusChanged') { + statusChangedHandler = handler; + } + }); + + return { + subscribe: localSubscribeMock, + unsubscribe: jest.fn(), + } as unknown as TransactionControllerMessenger; +} + describe('IncomingTransactionHelper', () => { let subscribeMock: jest.Mock; let unsubscribeMock: jest.Mock; @@ -1073,4 +1104,221 @@ describe('IncomingTransactionHelper', () => { expect(listener).not.toHaveBeenCalled(); }); }); + + describe('network fallback mechanism', () => { + describe('when useWebsockets is true', () => { + beforeEach(() => { + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(true); + }); + + it('starts polling when a supported network goes down', async () => { + const messenger = createMessengerMockWithStatusChanged(); + + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + await flushPromises(); + + expect(jest.getTimerCount()).toBe(0); + + // First, bring all supported networks UP + statusChangedHandler({ + chainIds: SUPPORTED_CAIP2_CHAINS, + status: 'up', + }); + + await flushPromises(); + + // All networks are up, so polling should not be running + expect(jest.getTimerCount()).toBe(0); + + // When one supported network goes down, polling should start + // because not all supported networks are up + statusChangedHandler({ + chainIds: [MAINNET_CAIP2], + status: 'down', + }); + + await flushPromises(); + + expect(jest.getTimerCount()).toBe(1); + }); + + it('continues polling when one network comes up but others are still down', async () => { + const messenger = createMessengerMockWithStatusChanged(); + + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + await flushPromises(); + + // First, bring all supported networks UP + statusChangedHandler({ + chainIds: SUPPORTED_CAIP2_CHAINS, + status: 'up', + }); + + await flushPromises(); + + // Bring down two networks + statusChangedHandler({ + chainIds: [MAINNET_CAIP2, POLYGON_CAIP2], + status: 'down', + }); + + await flushPromises(); + + expect(jest.getTimerCount()).toBe(1); + + // Bring one network back up, but others are still down + statusChangedHandler({ + chainIds: [MAINNET_CAIP2], + status: 'up', + }); + + await flushPromises(); + + // Polling should continue because not all networks are up + expect(jest.getTimerCount()).toBe(1); + }); + + it('stops polling when all supported networks are back up', async () => { + const messenger = createMessengerMockWithStatusChanged(); + + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + await flushPromises(); + + // First, bring all supported networks UP + statusChangedHandler({ + chainIds: SUPPORTED_CAIP2_CHAINS, + status: 'up', + }); + + await flushPromises(); + + // Bring down all supported networks + statusChangedHandler({ + chainIds: SUPPORTED_CAIP2_CHAINS, + status: 'down', + }); + + await flushPromises(); + + expect(jest.getTimerCount()).toBe(1); + + // Bring all supported networks back up + statusChangedHandler({ + chainIds: SUPPORTED_CAIP2_CHAINS, + status: 'up', + }); + + await flushPromises(); + + // Polling should stop because all networks are up + expect(jest.getTimerCount()).toBe(0); + }); + + it('does not start polling again if already polling', async () => { + const messenger = createMessengerMockWithStatusChanged(); + const remoteTransactionSource = createRemoteTransactionSourceMock([]); + + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger, + remoteTransactionSource, + }); + + await flushPromises(); + + // First, bring all supported networks UP + statusChangedHandler({ + chainIds: SUPPORTED_CAIP2_CHAINS, + status: 'up', + }); + + await flushPromises(); + + // First network goes down, polling starts + statusChangedHandler({ + chainIds: [MAINNET_CAIP2], + status: 'down', + }); + + await flushPromises(); + + expect(jest.getTimerCount()).toBe(1); + + // Another network goes down, but polling is already running + statusChangedHandler({ + chainIds: [POLYGON_CAIP2], + status: 'down', + }); + + await flushPromises(); + + // Should still have only 1 timer (no duplicate polling) + expect(jest.getTimerCount()).toBe(1); + }); + + it('does not start polling before first statusChanged event', async () => { + const messenger = createMessengerMockWithStatusChanged(); + + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + await flushPromises(); + + // Polling should not start automatically on initialization + expect(jest.getTimerCount()).toBe(0); + }); + }); + + describe('when useWebsockets is false', () => { + it('does not subscribe to statusChanged events', async () => { + jest + .mocked(isIncomingTransactionsUseWebsocketsEnabled) + .mockReturnValue(false); + const localSubscribeMock = jest.fn(); + const messenger = { + subscribe: localSubscribeMock, + unsubscribe: jest.fn(), + } as unknown as TransactionControllerMessenger; + + // eslint-disable-next-line no-new + new IncomingTransactionHelper({ + ...CONTROLLER_ARGS_MOCK, + messenger, + remoteTransactionSource: createRemoteTransactionSourceMock([]), + }); + + await flushPromises(); + + expect(localSubscribeMock).not.toHaveBeenCalledWith( + 'AccountActivityService:statusChanged', + expect.any(Function), + ); + }); + }); + }); }); diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 628caef94ac..f6698593789 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -1,9 +1,11 @@ import type { AccountsController } from '@metamask/accounts-controller'; +import { toHex } from '@metamask/controller-utils'; import type { Transaction as AccountActivityTransaction, WebSocketConnectionInfo, } 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 +17,7 @@ import { getIncomingTransactionsPollingInterval, isIncomingTransactionsUseWebsocketsEnabled, } from '../utils/feature-flags'; +import { SUPPORTED_CHAIN_IDS } from './AccountsApiRemoteTransactionSource'; export type IncomingTransactionOptions = { /** Name of the client to include in requests. */ @@ -73,6 +76,9 @@ export class IncomingTransactionHelper { readonly #useWebsockets: boolean; + // Chains that need polling (start with all supported, remove as they come up) + readonly #chainsToPolls: Hex[] = [...SUPPORTED_CHAIN_IDS]; + readonly #connectionStateChangedHandler = ( connectionInfo: WebSocketConnectionInfo, ): void => { @@ -89,6 +95,16 @@ export class IncomingTransactionHelper { this.#onSelectedAccountChanged(); }; + readonly #statusChangedHandler = ({ + chainIds, + status, + }: { + chainIds: string[]; + status: 'up' | 'down'; + }): void => { + this.#onNetworkStatusChanged(chainIds, status); + }; + constructor({ client, getCurrentAccount, @@ -132,11 +148,25 @@ export class IncomingTransactionHelper { 'BackendWebSocketService:connectionStateChanged', this.#connectionStateChangedHandler, ); + + this.#messenger.subscribe( + 'AccountActivityService:statusChanged', + this.#statusChangedHandler, + ); } } start(): void { - if (this.#isRunning || this.#useWebsockets) { + // When websockets are disabled, allow normal polling (legacy mode) + if (this.#useWebsockets) { + return; + } + + this.#startPolling(); + } + + #startPolling(): void { + if (this.#isRunning) { return; } @@ -146,7 +176,9 @@ export class IncomingTransactionHelper { const interval = this.#getInterval(); - log('Started polling', { interval }); + log('Started polling', { + interval, + }); this.#isRunning = true; @@ -155,7 +187,7 @@ export class IncomingTransactionHelper { } this.#onInterval().catch((error) => { - log('Initial polling failed', error); + log('Polling failed', error); }); } @@ -287,6 +319,7 @@ export class IncomingTransactionHelper { remoteTransactions = await this.#remoteTransactionSource.fetchTransactions({ address: account.address as Hex, + chainIds: this.#chainsToPolls, includeTokenTransfers, tags: finalTags, updateTransactions, @@ -316,7 +349,7 @@ export class IncomingTransactionHelper { (currentTx) => currentTx.hash?.toLowerCase() === tx.hash?.toLowerCase() && currentTx.txParams.from?.toLowerCase() === - tx.txParams.from?.toLowerCase() && + tx.txParams.from?.toLowerCase() && currentTx.type === tx.type, ), ); @@ -383,4 +416,70 @@ 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; + } + + let hasChanges = false; + + for (const caip2ChainId of chainIds) { + const hexChainId = this.#caip2ToHex(caip2ChainId); + + if (!hexChainId || !SUPPORTED_CHAIN_IDS.includes(hexChainId)) { + continue; + } + + if (status === 'up') { + const index = this.#chainsToPolls.indexOf(hexChainId); + if (index !== -1) { + this.#chainsToPolls.splice(index, 1); + hasChanges = true; + log('Supported network came up, removed from polling list', { + chainId: hexChainId, + }); + } + } else if (!this.#chainsToPolls.includes(hexChainId)) { + // status === 'down' + this.#chainsToPolls.push(hexChainId); + hasChanges = true; + log('Supported network went down, added to polling list', { + chainId: hexChainId, + }); + } + } + + if (!hasChanges) { + return; + } + + if (this.#chainsToPolls.length === 0) { + log('Stopping fallback polling - all networks up'); + this.stop(); + } else { + log('Starting fallback polling - some networks need polling', { + chainsToPolls: this.#chainsToPolls, + }); + this.#startPolling(); + } + } } diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 59776b77b0b..5fed8c1a5f0 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1117,6 +1117,12 @@ export interface RemoteTransactionSourceRequest { */ address: Hex; + /** + * Optional array of chain IDs to fetch transactions for. + * If not provided, defaults to all supported chains. + */ + chainIds?: Hex[]; + /** * Whether to also include incoming token transfers. */ From 418065beecd84a22b72f71c31d74001260f1f0e8 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 28 Jan 2026 20:24:40 +0530 Subject: [PATCH 02/10] update --- .../src/helpers/IncomingTransactionHelper.test.ts | 1 - .../src/helpers/IncomingTransactionHelper.ts | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts index 47c7257d2a0..204397734b7 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.test.ts @@ -135,7 +135,6 @@ async function runInterval( const MAINNET_CAIP2 = 'eip155:1'; const POLYGON_CAIP2 = 'eip155:137'; -const UNSUPPORTED_CAIP2 = 'eip155:2457'; // Helper to convert hex chain ID to CAIP-2 format const hexToCaip2 = (hexChainId: string): string => { diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index f6698593789..e4b96f8b1c5 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -305,7 +305,11 @@ export class IncomingTransactionHelper { tags: finalTags, }); - if (!this.#canStart()) { + if (!this.#canStart() || this.#chainsToPolls.length === 0) { + log('Cannot start polling', { + canStart: this.#canStart(), + chainsToPolls: this.#chainsToPolls, + }); return; } From ea1a724d36827216f3f8f2d36535fc81834b870a Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 28 Jan 2026 20:36:48 +0530 Subject: [PATCH 03/10] update --- .../src/helpers/IncomingTransactionHelper.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index e4b96f8b1c5..7a140c20383 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -4,6 +4,7 @@ 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. @@ -38,11 +39,6 @@ export type IncomingTransactionOptions = { const TAG_POLLING = 'automatic-polling'; -enum WebSocketState { - CONNECTED = 'connected', - DISCONNECTED = 'disconnected', -} - export class IncomingTransactionHelper { hub: EventEmitter; @@ -305,11 +301,7 @@ export class IncomingTransactionHelper { tags: finalTags, }); - if (!this.#canStart() || this.#chainsToPolls.length === 0) { - log('Cannot start polling', { - canStart: this.#canStart(), - chainsToPolls: this.#chainsToPolls, - }); + if (!this.#canStart()) { return; } @@ -317,13 +309,19 @@ export class IncomingTransactionHelper { const includeTokenTransfers = this.#includeTokenTransfers ?? true; const updateTransactions = this.#updateTransactions ?? false; + // When websockets enabled and we have specific chains to poll, only fetch those + const chainIds = + this.#useWebsockets && this.#chainsToPolls.length > 0 + ? this.#chainsToPolls + : undefined; + let remoteTransactions: TransactionMeta[] = []; try { remoteTransactions = await this.#remoteTransactionSource.fetchTransactions({ address: account.address as Hex, - chainIds: this.#chainsToPolls, + chainIds, includeTokenTransfers, tags: finalTags, updateTransactions, From 1c679a828f030731ed892420bc1140d49abd43d8 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 28 Jan 2026 20:38:11 +0530 Subject: [PATCH 04/10] update --- .../src/helpers/IncomingTransactionHelper.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 7a140c20383..d20dd44c020 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -73,7 +73,7 @@ export class IncomingTransactionHelper { readonly #useWebsockets: boolean; // Chains that need polling (start with all supported, remove as they come up) - readonly #chainsToPolls: Hex[] = [...SUPPORTED_CHAIN_IDS]; + readonly #chainsToPoll: Hex[] = [...SUPPORTED_CHAIN_IDS]; readonly #connectionStateChangedHandler = ( connectionInfo: WebSocketConnectionInfo, @@ -311,8 +311,8 @@ export class IncomingTransactionHelper { // When websockets enabled and we have specific chains to poll, only fetch those const chainIds = - this.#useWebsockets && this.#chainsToPolls.length > 0 - ? this.#chainsToPolls + this.#useWebsockets && this.#chainsToPoll.length > 0 + ? this.#chainsToPoll : undefined; let remoteTransactions: TransactionMeta[] = []; @@ -452,17 +452,17 @@ export class IncomingTransactionHelper { } if (status === 'up') { - const index = this.#chainsToPolls.indexOf(hexChainId); + const index = this.#chainsToPoll.indexOf(hexChainId); if (index !== -1) { - this.#chainsToPolls.splice(index, 1); + this.#chainsToPoll.splice(index, 1); hasChanges = true; log('Supported network came up, removed from polling list', { chainId: hexChainId, }); } - } else if (!this.#chainsToPolls.includes(hexChainId)) { + } else if (!this.#chainsToPoll.includes(hexChainId)) { // status === 'down' - this.#chainsToPolls.push(hexChainId); + this.#chainsToPoll.push(hexChainId); hasChanges = true; log('Supported network went down, added to polling list', { chainId: hexChainId, @@ -474,12 +474,12 @@ export class IncomingTransactionHelper { return; } - if (this.#chainsToPolls.length === 0) { + if (this.#chainsToPoll.length === 0) { log('Stopping fallback polling - all networks up'); this.stop(); } else { log('Starting fallback polling - some networks need polling', { - chainsToPolls: this.#chainsToPolls, + chainsToPoll: this.#chainsToPoll, }); this.#startPolling(); } From b89973734b6568468b4960293ad1a0ec623796b0 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 28 Jan 2026 20:55:22 +0530 Subject: [PATCH 05/10] update --- packages/transaction-controller/CHANGELOG.md | 1 + .../src/helpers/IncomingTransactionHelper.ts | 21 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 9c4dda2ef91..574ed100410 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `perpsDepositAndOrder` to `TransactionType` ([#7755](https://github.com/MetaMask/core/pull/7755)) +- Add network fallback mechanism to `IncomingTransactionHelper` for WebSocket-based transaction retrieval ([#7759](https://github.com/MetaMask/core/pull/7759)) ## [62.10.0] diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index d20dd44c020..200e5af6902 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -11,6 +11,7 @@ import { isCaipChainId, parseCaipChainId } from '@metamask/utils'; // eslint-disable-next-line import-x/no-nodejs-modules import EventEmitter from 'events'; +import { SUPPORTED_CHAIN_IDS } from './AccountsApiRemoteTransactionSource'; import type { TransactionControllerMessenger } from '..'; import { incomingTransactionsLogger as log } from '../logger'; import type { RemoteTransactionSource, TransactionMeta } from '../types'; @@ -18,7 +19,6 @@ import { getIncomingTransactionsPollingInterval, isIncomingTransactionsUseWebsocketsEnabled, } from '../utils/feature-flags'; -import { SUPPORTED_CHAIN_IDS } from './AccountsApiRemoteTransactionSource'; export type IncomingTransactionOptions = { /** Name of the client to include in requests. */ @@ -270,7 +270,9 @@ export class IncomingTransactionHelper { this.#isUpdating = true; try { - await this.update({ isInterval: true }); + // When websockets enabled, only poll chains that are not confirmed up + const chainIds = this.#useWebsockets ? this.#chainsToPoll : undefined; + await this.update({ chainIds, isInterval: true }); } catch (error) { console.error('Error while checking incoming transactions', error); } @@ -291,9 +293,14 @@ export class IncomingTransactionHelper { } async update({ + chainIds, isInterval, tags, - }: { isInterval?: boolean; tags?: string[] } = {}): Promise { + }: { + chainIds?: Hex[]; + isInterval?: boolean; + tags?: string[]; + } = {}): Promise { const finalTags = this.#getTags(tags, isInterval); log('Checking for incoming transactions', { @@ -309,12 +316,6 @@ export class IncomingTransactionHelper { const includeTokenTransfers = this.#includeTokenTransfers ?? true; const updateTransactions = this.#updateTransactions ?? false; - // When websockets enabled and we have specific chains to poll, only fetch those - const chainIds = - this.#useWebsockets && this.#chainsToPoll.length > 0 - ? this.#chainsToPoll - : undefined; - let remoteTransactions: TransactionMeta[] = []; try { @@ -351,7 +352,7 @@ export class IncomingTransactionHelper { (currentTx) => currentTx.hash?.toLowerCase() === tx.hash?.toLowerCase() && currentTx.txParams.from?.toLowerCase() === - tx.txParams.from?.toLowerCase() && + tx.txParams.from?.toLowerCase() && currentTx.type === tx.type, ), ); From cad52e19e2e54b99a9e93ff30bc44e710338aba9 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 29 Jan 2026 15:11:11 +0530 Subject: [PATCH 06/10] update --- packages/eth-json-rpc-middleware/src/wallet.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts index 581eee55d80..beaf2fc757a 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.test.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -1,6 +1,7 @@ import { MessageTypes, TypedMessage } from '@metamask/eth-sig-util'; import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import { createHandleParams, createRequest } from '../test/util/helpers'; import type { MessageParams, TransactionParams, @@ -8,7 +9,6 @@ import type { TypedMessageV1Params, } from '.'; import { createWalletMiddleware } from '.'; -import { createHandleParams, createRequest } from '../test/util/helpers'; const testAddresses = [ '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb', From 5d4310d9251fd8d53976f67d930fbb83568f8758 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 29 Jan 2026 15:17:24 +0530 Subject: [PATCH 07/10] update --- packages/eth-json-rpc-middleware/src/wallet.test.ts | 2 +- packages/transaction-controller/CHANGELOG.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts index beaf2fc757a..581eee55d80 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.test.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -1,7 +1,6 @@ import { MessageTypes, TypedMessage } from '@metamask/eth-sig-util'; import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; -import { createHandleParams, createRequest } from '../test/util/helpers'; import type { MessageParams, TransactionParams, @@ -9,6 +8,7 @@ import type { TypedMessageV1Params, } from '.'; import { createWalletMiddleware } from '.'; +import { createHandleParams, createRequest } from '../test/util/helpers'; const testAddresses = [ '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb', diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 54f2345354f..35c0b12cf7a 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,12 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add network fallback mechanism to `IncomingTransactionHelper` for WebSocket-based transaction retrieval ([#7759](https://github.com/MetaMask/core/pull/7759)) + ## [62.11.0] ### Added - Add `perpsDepositAndOrder` to `TransactionType` ([#7755](https://github.com/MetaMask/core/pull/7755)) -- Add network fallback mechanism to `IncomingTransactionHelper` for WebSocket-based transaction retrieval ([#7759](https://github.com/MetaMask/core/pull/7759)) ### Changed From da8b2109dc3b060112fa156acf7b0fa4f328b47c Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 29 Jan 2026 19:09:26 +0530 Subject: [PATCH 08/10] update --- .../src/helpers/IncomingTransactionHelper.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 200e5af6902..bb46c88d13a 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -158,10 +158,10 @@ export class IncomingTransactionHelper { return; } - this.#startPolling(); + this.#startPolling(true); } - #startPolling(): void { + #startPolling(initialPolling = false): void { if (this.#isRunning) { return; } @@ -183,7 +183,7 @@ export class IncomingTransactionHelper { } this.#onInterval().catch((error) => { - log('Polling failed', error); + log(initialPolling ? 'Initial polling failed' : 'Polling failed', error); }); } @@ -352,7 +352,7 @@ export class IncomingTransactionHelper { (currentTx) => currentTx.hash?.toLowerCase() === tx.hash?.toLowerCase() && currentTx.txParams.from?.toLowerCase() === - tx.txParams.from?.toLowerCase() && + tx.txParams.from?.toLowerCase() && currentTx.type === tx.type, ), ); @@ -449,6 +449,10 @@ export class IncomingTransactionHelper { const hexChainId = this.#caip2ToHex(caip2ChainId); if (!hexChainId || !SUPPORTED_CHAIN_IDS.includes(hexChainId)) { + log('Chain ID not recognized or not supported', { + caip2ChainId, + hexChainId, + }); continue; } @@ -461,8 +465,7 @@ export class IncomingTransactionHelper { chainId: hexChainId, }); } - } else if (!this.#chainsToPoll.includes(hexChainId)) { - // status === 'down' + } else if (status === 'down' && !this.#chainsToPoll.includes(hexChainId)) { this.#chainsToPoll.push(hexChainId); hasChanges = true; log('Supported network went down, added to polling list', { @@ -472,6 +475,9 @@ export class IncomingTransactionHelper { } if (!hasChanges) { + log('No changes to polling list', { + chainsToPoll: this.#chainsToPoll, + }); return; } From 8f8d2e6fae918b85b47c11a324a45de1468abb59 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 29 Jan 2026 19:11:51 +0530 Subject: [PATCH 09/10] update --- .../src/helpers/IncomingTransactionHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index bb46c88d13a..197f38bedae 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -352,7 +352,7 @@ export class IncomingTransactionHelper { (currentTx) => currentTx.hash?.toLowerCase() === tx.hash?.toLowerCase() && currentTx.txParams.from?.toLowerCase() === - tx.txParams.from?.toLowerCase() && + tx.txParams.from?.toLowerCase() && currentTx.type === tx.type, ), ); From 8c0d60b3ae3e98a5320a0dced12a25bd5ba7c605 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 29 Jan 2026 19:20:19 +0530 Subject: [PATCH 10/10] linting --- .../src/helpers/IncomingTransactionHelper.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index 197f38bedae..efd419cfc5a 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -465,7 +465,10 @@ export class IncomingTransactionHelper { chainId: hexChainId, }); } - } else if (status === 'down' && !this.#chainsToPoll.includes(hexChainId)) { + } else if ( + status === 'down' && + !this.#chainsToPoll.includes(hexChainId) + ) { this.#chainsToPoll.push(hexChainId); hasChanges = true; log('Supported network went down, added to polling list', {