diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f9c15e0f526..35c0b12cf7a 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 network fallback mechanism to `IncomingTransactionHelper` for WebSocket-based transaction retrieval ([#7759](https://github.com/MetaMask/core/pull/7759)) + ## [62.11.0] ### Added 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..204397734b7 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,36 @@ async function runInterval( }; } +const MAINNET_CAIP2 = 'eip155:1'; +const POLYGON_CAIP2 = 'eip155:137'; + +// Helper to convert hex chain ID to CAIP-2 format +const hexToCaip2 = (hexChainId: string): string => { + const decimal = parseInt(hexChainId, 16); + return `eip155:${decimal}`; +}; + +// Convert all supported hex chain IDs to CAIP-2 format for testing +const SUPPORTED_CAIP2_CHAINS = SUPPORTED_CHAIN_IDS.map(hexToCaip2); + +let statusChangedHandler: (event: { + chainIds: string[]; + status: 'up' | 'down'; +}) => void; + +function createMessengerMockWithStatusChanged(): TransactionControllerMessenger { + const localSubscribeMock = jest.fn().mockImplementation((event, handler) => { + if (event === 'AccountActivityService:statusChanged') { + statusChangedHandler = handler; + } + }); + + return { + subscribe: localSubscribeMock, + unsubscribe: jest.fn(), + } as unknown as TransactionControllerMessenger; +} + describe('IncomingTransactionHelper', () => { let subscribeMock: jest.Mock; let unsubscribeMock: jest.Mock; @@ -1073,4 +1103,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..efd419cfc5a 100644 --- a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts +++ b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts @@ -1,13 +1,17 @@ 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'; +import { SUPPORTED_CHAIN_IDS } from './AccountsApiRemoteTransactionSource'; import type { TransactionControllerMessenger } from '..'; import { incomingTransactionsLogger as log } from '../logger'; import type { RemoteTransactionSource, TransactionMeta } from '../types'; @@ -35,11 +39,6 @@ export type IncomingTransactionOptions = { const TAG_POLLING = 'automatic-polling'; -enum WebSocketState { - CONNECTED = 'connected', - DISCONNECTED = 'disconnected', -} - export class IncomingTransactionHelper { hub: EventEmitter; @@ -73,6 +72,9 @@ export class IncomingTransactionHelper { readonly #useWebsockets: boolean; + // Chains that need polling (start with all supported, remove as they come up) + readonly #chainsToPoll: Hex[] = [...SUPPORTED_CHAIN_IDS]; + readonly #connectionStateChangedHandler = ( connectionInfo: WebSocketConnectionInfo, ): void => { @@ -89,6 +91,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 +144,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(true); + } + + #startPolling(initialPolling = false): void { + if (this.#isRunning) { return; } @@ -146,7 +172,9 @@ export class IncomingTransactionHelper { const interval = this.#getInterval(); - log('Started polling', { interval }); + log('Started polling', { + interval, + }); this.#isRunning = true; @@ -155,7 +183,7 @@ export class IncomingTransactionHelper { } this.#onInterval().catch((error) => { - log('Initial polling failed', error); + log(initialPolling ? 'Initial polling failed' : 'Polling failed', error); }); } @@ -242,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); } @@ -263,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', { @@ -287,6 +322,7 @@ export class IncomingTransactionHelper { remoteTransactions = await this.#remoteTransactionSource.fetchTransactions({ address: account.address as Hex, + chainIds, includeTokenTransfers, tags: finalTags, updateTransactions, @@ -383,4 +419,79 @@ 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)) { + log('Chain ID not recognized or not supported', { + caip2ChainId, + hexChainId, + }); + continue; + } + + if (status === 'up') { + const index = this.#chainsToPoll.indexOf(hexChainId); + if (index !== -1) { + this.#chainsToPoll.splice(index, 1); + hasChanges = true; + log('Supported network came up, removed from polling list', { + chainId: hexChainId, + }); + } + } else if ( + status === 'down' && + !this.#chainsToPoll.includes(hexChainId) + ) { + this.#chainsToPoll.push(hexChainId); + hasChanges = true; + log('Supported network went down, added to polling list', { + chainId: hexChainId, + }); + } + } + + if (!hasChanges) { + log('No changes to polling list', { + chainsToPoll: this.#chainsToPoll, + }); + return; + } + + if (this.#chainsToPoll.length === 0) { + log('Stopping fallback polling - all networks up'); + this.stop(); + } else { + log('Starting fallback polling - some networks need polling', { + chainsToPoll: this.#chainsToPoll, + }); + this.#startPolling(); + } + } } diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 413bb591164..87e3dceef95 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1128,6 +1128,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. */