diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 5d9709eb97c..db4a3b5d6e1 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add event-driven transaction polling via `AccountActivityService:transactionUpdated` ([#7822](https://github.com/MetaMask/core/pull/7822)) - Add optional `requiredAssets` to `TransactionMeta` ([#7820](https://github.com/MetaMask/core/pull/7820)) ### Changed diff --git a/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts b/packages/transaction-controller/src/helpers/IncomingTransactionHelper.ts index efd419cfc5a..08529c5bbfd 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'; @@ -19,6 +17,7 @@ import { getIncomingTransactionsPollingInterval, isIncomingTransactionsUseWebsocketsEnabled, } from '../utils/feature-flags'; +import { caip2ToHex } from '../utils/utils'; export type IncomingTransactionOptions = { /** Name of the client to include in requests. */ @@ -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..f9a5fb25cbf 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,11 +19,23 @@ 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 SELECTED_ACCOUNT_MOCK = { + address: '0x123', +}; + +const createMessengerMock = (): jest.Mocked => + ({ + call: jest.fn().mockImplementation((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return SELECTED_ACCOUNT_MOCK; + } + return {}; + }), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }) as unknown as jest.Mocked; + +let MESSENGER_MOCK: jest.Mocked; jest.mock('../utils/feature-flags', () => ({ getAcceleratedPollingParams: (): { @@ -32,23 +45,24 @@ jest.mock('../utils/feature-flags', () => ({ countMax: DEFAULT_ACCELERATED_COUNT_MAX, intervalMs: DEFAULT_ACCELERATED_POLLING_INTERVAL_MS, }), - FEATURE_FLAG_TRANSACTIONS: 'confirmations_transactions', })); /** * 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', () => { beforeEach(() => { jest.resetAllMocks(); jest.clearAllTimers(); + MESSENGER_MOCK = createMessengerMock(); }); describe('Accelerated Polling', () => { @@ -335,4 +349,355 @@ describe('TransactionPoller', () => { }, ); }); + + describe('AccountActivityService:transactionUpdated event', () => { + it('subscribes to event when started', () => { + 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('unsubscribes from event when stopped', () => { + 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.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, + }); + + 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, + 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 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); + + 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); + }); + + 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(); + }); + + 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 7c9ad49baff..167030441b2 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'; @@ -7,9 +8,16 @@ 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'; const log = createModuleLogger(projectLogger, 'transaction-poller'); +enum IntervalTrigger { + Accelerated = 'Accelerated', + BlockTracker = 'BlockTracker', + Websocket = 'Websocket', +} + /** * Helper class to orchestrate when to poll pending transactions. * Initially starts polling via a timeout chain every 2 seconds up to 5 times. @@ -61,6 +69,8 @@ export class TransactionPoller { this.#listener = listener; this.#running = true; + this.#subscribeToTransactionUpdates(); + this.#queue(); log('Started'); @@ -82,6 +92,7 @@ export class TransactionPoller { this.#stopTimeout(); this.#stopBlockTracker(); + this.#unsubscribeFromTransactionUpdates(); log('Stopped'); } @@ -134,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); @@ -147,19 +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, ): Promise { - if (isAccelerated) { - log('Accelerated interval', this.#acceleratedCount + 1); - } else { - log('Block tracker interval', latestBlockNumber); + switch (trigger) { + case IntervalTrigger.Websocket: + 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 = @@ -167,7 +186,7 @@ export class TransactionPoller { await this.#listener?.(latestBlockNumberFinal); - if (isAccelerated && this.#running) { + if (trigger === IntervalTrigger.Accelerated && this.#running) { this.#acceleratedCount += 1; } } @@ -189,4 +208,57 @@ export class TransactionPoller { this.#blockTracker.removeListener('latest', this.#blockTrackerListener); this.#blockTrackerListener = undefined; } + + readonly #transactionUpdatedHandler = (transaction: Transaction): void => { + if (!this.#running) { + return; + } + + const hexChainId = caip2ToHex(transaction.chain); + if (hexChainId !== this.#chainId) { + return; + } + + if ( + transaction.status !== 'confirmed' && + transaction.status !== 'dropped' && + transaction.status !== 'failed' + ) { + return; + } + + const selectedAccount = this.#messenger.call( + 'AccountsController:getSelectedAccount', + ); + if ( + selectedAccount.address.toLowerCase() !== transaction.from.toLowerCase() + ) { + 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 + }); + }; + + #subscribeToTransactionUpdates(): void { + this.#messenger.subscribe( + 'AccountActivityService:transactionUpdated', + this.#transactionUpdatedHandler, + ); + } + + #unsubscribeFromTransactionUpdates(): void { + this.#messenger.unsubscribe( + 'AccountActivityService:transactionUpdated', + this.#transactionUpdatedHandler, + ); + } } 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; + } +}