diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 6f0937efa49..969ef113cb1 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 sequential batch support when `publishBatchHook` is not defined ([#5762](https://github.com/MetaMask/core/pull/5762)) + ### Fixed - Fix `addTransaction` function to correctly identify a transaction as a `simpleSend` type when the recipient is a smart account ([#5822](https://github.com/MetaMask/core/pull/5822)) diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 2adc50a9282..20d7fdf91dd 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -1024,6 +1024,11 @@ export class TransactionController extends BaseController< async addTransactionBatch( request: TransactionBatchRequest, ): Promise { + const { blockTracker } = this.messagingSystem.call( + `NetworkController:getNetworkClientById`, + request.networkClientId, + ); + return await addTransactionBatch({ addTransaction: this.addTransaction.bind(this), getChainId: this.#getChainId.bind(this), @@ -1036,6 +1041,19 @@ export class TransactionController extends BaseController< publicKeyEIP7702: this.#publicKeyEIP7702, request, updateTransaction: this.#updateTransactionInternal.bind(this), + publishTransaction: ( + ethQuery: EthQuery, + transactionMeta: TransactionMeta, + ) => this.#publishTransaction(ethQuery, transactionMeta) as Promise, + getPendingTransactionTrackerByChainId: ( + networkClientId: NetworkClientId, + ) => + this.#createPendingTransactionTracker({ + provider: this.#getProvider({ networkClientId }), + blockTracker, + chainId: this.#getChainId(networkClientId), + networkClientId, + }), }); } diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts index acab9f2c03d..baba496811a 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts @@ -1151,4 +1151,53 @@ describe('PendingTransactionTracker', () => { expect(transactionMeta.txReceipt).toBeUndefined(); }); }); + + describe('addTransactionToPoll', () => { + it('adds a transaction to poll and sets #transactionToForcePoll', () => { + pendingTransactionTracker = new PendingTransactionTracker(options); + + pendingTransactionTracker.addTransactionToPoll( + TRANSACTION_SUBMITTED_MOCK, + ); + + expect(transactionPoller.setPendingTransactions).toHaveBeenCalledWith([ + TRANSACTION_SUBMITTED_MOCK, + ]); + expect(transactionPoller.start).toHaveBeenCalledTimes(1); + }); + + describe('emits confirm event and clean transactionToForcePoll', () => { + it('if receipt has success status', async () => { + const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; + const getTransactions = jest + .fn() + .mockReturnValue(freeze([transaction], true)); + + pendingTransactionTracker = new PendingTransactionTracker({ + ...options, + getTransactions, + }); + + pendingTransactionTracker.addTransactionToPoll( + TRANSACTION_SUBMITTED_MOCK, + ); + + const listener = jest.fn(); + pendingTransactionTracker.hub.addListener( + 'transaction-confirmed', + listener, + ); + + queryMock.mockResolvedValueOnce(RECEIPT_MOCK); + queryMock.mockResolvedValueOnce(BLOCK_MOCK); + + await onPoll(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining(TRANSACTION_SUBMITTED_MOCK), + ); + }); + }); + }); }); diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index dec9b29d651..53754dd945b 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -93,6 +93,8 @@ export class PendingTransactionTracker { readonly #transactionPoller: TransactionPoller; + #transactionToForcePoll: TransactionMeta | undefined; + readonly #beforeCheckPendingTransaction: ( transactionMeta: TransactionMeta, ) => Promise; @@ -139,6 +141,7 @@ export class PendingTransactionTracker { this.#getGlobalLock = getGlobalLock; this.#publishTransaction = publishTransaction; this.#running = false; + this.#transactionToForcePoll = undefined; this.#transactionPoller = new TransactionPoller({ blockTracker, @@ -167,6 +170,22 @@ export class PendingTransactionTracker { } }; + /** + * Adds a transaction to the polling mechanism for monitoring its status. + * + * This method forcefully adds a single transaction to the list of transactions + * being polled, ensuring that its status is checked, event emitted but no update is performed. + * It overrides the default behavior by prioritizing the given transaction for polling. + * + * @param transactionMeta - The transaction metadata to be added for polling. + * + * The transaction will now be monitored for updates, such as confirmation or failure. + */ + addTransactionToPoll = (transactionMeta: TransactionMeta) => { + this.#start([transactionMeta]); + this.#transactionToForcePoll = transactionMeta; + }; + /** * Force checks the network if the given transaction is confirmed and updates it's status. * @@ -232,7 +251,14 @@ export class PendingTransactionTracker { async #checkTransactions() { this.#log('Checking transactions'); - const pendingTransactions = this.#getPendingTransactions(); + const pendingTransactions: TransactionMeta[] = [ + ...new Set( + [ + ...this.#getPendingTransactions(), + this.#transactionToForcePoll, + ].filter((tx): tx is TransactionMeta => tx !== undefined), + ), + ]; if (!pendingTransactions.length) { this.#log('No pending transactions to check'); @@ -353,6 +379,12 @@ export class PendingTransactionTracker { return blocksSinceFirstRetry >= requiredBlocksSinceFirstRetry; } + #cleanTransactionToForcePoll(txMeta: TransactionMeta) { + if (this.#transactionToForcePoll?.id === txMeta.id) { + this.#transactionToForcePoll = undefined; + } + } + async #checkTransaction(txMeta: TransactionMeta) { const { hash, id } = txMeta; @@ -429,6 +461,12 @@ export class PendingTransactionTracker { this.#log('Transaction confirmed', id); + if (this.#transactionToForcePoll) { + this.#cleanTransactionToForcePoll(txMeta); + this.hub.emit('transaction-confirmed', txMeta); + return; + } + const { baseFeePerGas, timestamp: blockTimestamp } = await this.#getBlockByHash(blockHash, false); @@ -525,11 +563,13 @@ export class PendingTransactionTracker { #failTransaction(txMeta: TransactionMeta, error: Error) { this.#log('Transaction failed', txMeta.id, error); + this.#cleanTransactionToForcePoll(txMeta); this.hub.emit('transaction-failed', txMeta, error); } #dropTransaction(txMeta: TransactionMeta) { this.#log('Transaction dropped', txMeta.id); + this.#cleanTransactionToForcePoll(txMeta); this.hub.emit('transaction-dropped', txMeta); } diff --git a/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts new file mode 100644 index 00000000000..76830d21959 --- /dev/null +++ b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.test.ts @@ -0,0 +1,369 @@ +import type EthQuery from '@metamask/eth-query'; +import type { Hex } from '@metamask/utils'; + +import { SequentialPublishBatchHook } from './SequentialPublishBatchHook'; +import { flushPromises } from '../../../../tests/helpers'; +import type { PendingTransactionTracker } from '../helpers/PendingTransactionTracker'; +import type { PublishBatchHookTransaction, TransactionMeta } from '../types'; + +jest.mock('@metamask/controller-utils', () => ({ + query: jest.fn(), +})); + +const TRANSACTION_HASH_MOCK = '0x123'; +const TRANSACTION_HASH_2_MOCK = '0x456'; +const NETWORK_CLIENT_ID_MOCK = 'testNetworkClientId'; +const TRANSACTION_ID_MOCK = 'testTransactionId'; +const TRANSACTION_ID_2_MOCK = 'testTransactionId2'; +const TRANSACTION_SIGNED_MOCK = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; +const TRANSACTION_SIGNED_2_MOCK = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567891'; +const TRANSACTION_PARAMS_MOCK = { + from: '0x1234567890abcdef1234567890abcdef12345678' as Hex, + to: '0xabcdef1234567890abcdef1234567890abcdef12' as Hex, + value: '0x1' as Hex, +}; +const TRANSACTION_1_MOCK = { + id: TRANSACTION_ID_MOCK, + signedTx: TRANSACTION_SIGNED_MOCK, + params: TRANSACTION_PARAMS_MOCK, +} as PublishBatchHookTransaction; +const TRANSACTION_2_MOCK = { + id: TRANSACTION_ID_2_MOCK, + signedTx: TRANSACTION_SIGNED_2_MOCK, + params: TRANSACTION_PARAMS_MOCK, +} as PublishBatchHookTransaction; + +const TRANSACTION_META_MOCK = { + id: TRANSACTION_ID_MOCK, + rawTx: '0xabcdef', +} as TransactionMeta; + +const TRANSACTION_META_2_MOCK = { + id: TRANSACTION_ID_2_MOCK, + rawTx: '0x123456', +} as TransactionMeta; + +describe('SequentialPublishBatchHook', () => { + const eventListeners: Record = {}; + let publishTransactionMock: jest.MockedFn< + (ethQuery: EthQuery, transactionMeta: TransactionMeta) => Promise + >; + let getTransactionMock: jest.MockedFn<(id: string) => TransactionMeta>; + let getEthQueryMock: jest.MockedFn<(networkClientId: string) => EthQuery>; + let ethQueryInstanceMock: EthQuery; + let pendingTransactionTrackerMock: jest.Mocked; + + /** + * Simulate an event from the pending transaction tracker. + * + * @param eventName - The name of the event to fire. + * @param args - Additional arguments to pass to the event handler. + */ + function firePendingTransactionTrackerEvent( + eventName: string, + ...args: unknown[] + ) { + eventListeners[eventName]?.forEach((callback) => callback(...args)); + } + + beforeEach(() => { + jest.resetAllMocks(); + + publishTransactionMock = jest.fn(); + getTransactionMock = jest.fn(); + getEthQueryMock = jest.fn(); + + ethQueryInstanceMock = {} as EthQuery; + getEthQueryMock.mockReturnValue(ethQueryInstanceMock); + + getTransactionMock.mockImplementation((id) => { + if (id === TRANSACTION_ID_MOCK) { + return TRANSACTION_META_MOCK; + } + if (id === TRANSACTION_ID_2_MOCK) { + return TRANSACTION_META_2_MOCK; + } + throw new Error(`Transaction with ID ${id} not found`); + }); + + pendingTransactionTrackerMock = { + hub: { + on: jest.fn((eventName, callback) => { + if (!eventListeners[eventName]) { + eventListeners[eventName] = []; + } + eventListeners[eventName].push(callback); + }), + removeAllListeners: jest.fn((eventName) => { + if (eventName) { + eventListeners[eventName] = []; + } else { + Object.keys(eventListeners).forEach((key) => { + eventListeners[key] = []; + }); + } + }), + }, + addTransactionToPoll: jest.fn(), + stop: jest.fn(), + } as unknown as jest.Mocked; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('publishes multiple transactions sequentially', async () => { + const transactions: PublishBatchHookTransaction[] = [ + TRANSACTION_1_MOCK, + TRANSACTION_2_MOCK, + ]; + + publishTransactionMock + .mockResolvedValueOnce(TRANSACTION_HASH_MOCK) + .mockResolvedValueOnce(TRANSACTION_HASH_2_MOCK); + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTrackerByChainId: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + const resultPromise = hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }); + + // Simulate confirmation for the first transaction + await flushPromises(); + firePendingTransactionTrackerEvent( + 'transaction-confirmed', + TRANSACTION_META_MOCK, + ); + + // Simulate confirmation for the second transaction + await flushPromises(); + firePendingTransactionTrackerEvent( + 'transaction-confirmed', + TRANSACTION_META_2_MOCK, + ); + + const result = await resultPromise; + + expect(result).toStrictEqual({ + results: [ + { transactionHash: TRANSACTION_HASH_MOCK }, + { transactionHash: TRANSACTION_HASH_2_MOCK }, + ], + }); + + expect(publishTransactionMock).toHaveBeenCalledTimes(2); + expect(publishTransactionMock).toHaveBeenNthCalledWith( + 1, + ethQueryInstanceMock, + TRANSACTION_META_MOCK, + ); + expect(publishTransactionMock).toHaveBeenNthCalledWith( + 2, + ethQueryInstanceMock, + TRANSACTION_META_2_MOCK, + ); + + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenCalledTimes(2); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: TRANSACTION_ID_MOCK, + hash: TRANSACTION_HASH_MOCK, + }), + ); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: TRANSACTION_ID_2_MOCK, + hash: TRANSACTION_HASH_2_MOCK, + }), + ); + + expect(pendingTransactionTrackerMock.hub.on).toHaveBeenCalledTimes(6); + expect( + pendingTransactionTrackerMock.hub.removeAllListeners, + ).toHaveBeenCalledTimes(6); + }); + + it('throws an error when publishTransaction fails', async () => { + const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK]; + + publishTransactionMock.mockRejectedValueOnce( + new Error('Failed to publish transaction'), + ); + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTrackerByChainId: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + await expect( + hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }), + ).rejects.toThrow('Failed to publish batch transaction'); + + expect(publishTransactionMock).toHaveBeenCalledTimes(1); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).not.toHaveBeenCalled(); + }); + + it('returns an empty result when transactions array is empty', async () => { + const transactions: PublishBatchHookTransaction[] = []; + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTrackerByChainId: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + const result = await hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }); + + expect(result).toStrictEqual({ results: [] }); + expect(publishTransactionMock).not.toHaveBeenCalled(); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).not.toHaveBeenCalled(); + }); + + it('handles transaction dropped event correctly', async () => { + const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK]; + + publishTransactionMock.mockResolvedValueOnce(TRANSACTION_HASH_MOCK); + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTrackerByChainId: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + const hookPromise = hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }); + + await flushPromises(); + + firePendingTransactionTrackerEvent( + 'transaction-dropped', + TRANSACTION_META_MOCK, + ); + + await expect(hookPromise).rejects.toThrow( + `Failed to publish batch transaction`, + ); + + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenCalledTimes(1); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: TRANSACTION_ID_MOCK, + hash: TRANSACTION_HASH_MOCK, + }), + ); + + expect( + pendingTransactionTrackerMock.hub.removeAllListeners, + ).toHaveBeenCalledTimes(3); + expect(publishTransactionMock).toHaveBeenCalledTimes(1); + }); + + it('handles transaction failed event correctly', async () => { + const transactions: PublishBatchHookTransaction[] = [TRANSACTION_1_MOCK]; + + publishTransactionMock.mockResolvedValueOnce(TRANSACTION_HASH_MOCK); + + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: publishTransactionMock, + getTransaction: getTransactionMock, + getEthQuery: getEthQueryMock, + getPendingTransactionTrackerByChainId: jest + .fn() + .mockReturnValue(pendingTransactionTrackerMock), + }); + + const hook = sequentialPublishBatchHook.getHook(); + + const hookPromise = hook({ + from: '0x123', + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions, + }); + + await flushPromises(); + + firePendingTransactionTrackerEvent( + 'transaction-failed', + TRANSACTION_META_MOCK, + new Error('Transaction failed'), + ); + + await expect(hookPromise).rejects.toThrow( + `Failed to publish batch transaction`, + ); + + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenCalledTimes(1); + expect( + pendingTransactionTrackerMock.addTransactionToPoll, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: TRANSACTION_ID_MOCK, + hash: TRANSACTION_HASH_MOCK, + }), + ); + + expect( + pendingTransactionTrackerMock.hub.removeAllListeners, + ).toHaveBeenCalledTimes(3); + expect(publishTransactionMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts new file mode 100644 index 00000000000..62a02c5a0f4 --- /dev/null +++ b/packages/transaction-controller/src/hooks/SequentialPublishBatchHook.ts @@ -0,0 +1,227 @@ +import type EthQuery from '@metamask/eth-query'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { PendingTransactionTracker } from '../helpers/PendingTransactionTracker'; +import { projectLogger } from '../logger'; +import { + type PublishBatchHook, + type PublishBatchHookRequest, + type PublishBatchHookResult, + type TransactionMeta, +} from '../types'; + +const log = createModuleLogger(projectLogger, 'sequential-publish-batch-hook'); + +type SequentialPublishBatchHookOptions = { + publishTransaction: ( + ethQuery: EthQuery, + transactionMeta: TransactionMeta, + ) => Promise; + getTransaction: (id: string) => TransactionMeta; + getEthQuery: (networkClientId: string) => EthQuery; + getPendingTransactionTrackerByChainId: ( + networkClientId: string, + ) => PendingTransactionTracker; +}; + +type TrackerListenersOptions = { + pendingTransactionTracker: PendingTransactionTracker; + onConfirmed: (txMeta: TransactionMeta) => void; + onFailedOrDropped: (txMeta: TransactionMeta, error?: Error) => void; +}; + +type OnConfirmedHandlerOptions = { + transactionMeta: TransactionMeta; + transactionHash: string; + resolve: (txMeta: TransactionMeta) => void; + pendingTransactionTracker: PendingTransactionTracker; +}; + +type OnFailedOrDroppedHandlerOptions = { + transactionMeta: TransactionMeta; + transactionHash: string; + reject: (error: Error) => void; + pendingTransactionTracker: PendingTransactionTracker; +}; + +/** + * Custom publish logic that also publishes additional sequential transactions in an batch. + * Requires the batch to be successful to resolve. + */ +export class SequentialPublishBatchHook { + readonly #publishTransaction: ( + ethQuery: EthQuery, + transactionMeta: TransactionMeta, + ) => Promise; + + readonly #getTransaction: (id: string) => TransactionMeta; + + readonly #getEthQuery: (networkClientId: string) => EthQuery; + + readonly #getPendingTransactionTrackerByChainId: ( + networkClientId: string, + ) => PendingTransactionTracker; + + constructor({ + publishTransaction, + getTransaction, + getPendingTransactionTrackerByChainId, + getEthQuery, + }: SequentialPublishBatchHookOptions) { + this.#publishTransaction = publishTransaction; + this.#getTransaction = getTransaction; + this.#getEthQuery = getEthQuery; + this.#getPendingTransactionTrackerByChainId = + getPendingTransactionTrackerByChainId; + } + + /** + * @returns The publish batch hook function. + */ + getHook(): PublishBatchHook { + return this.#hook.bind(this); + } + + async #hook({ + from, + networkClientId, + transactions, + }: PublishBatchHookRequest): Promise { + log('Starting sequential publish batch hook', { from, networkClientId }); + + const pendingTransactionTracker = + this.#getPendingTransactionTrackerByChainId(networkClientId); + const results = []; + + for (const transaction of transactions) { + try { + const transactionMeta = this.#getTransaction(String(transaction.id)); + + const transactionHash = await this.#publishTransaction( + this.#getEthQuery(networkClientId), + transactionMeta, + ); + log('Transaction published', { transactionHash }); + + const transactionUpdated = { + ...transactionMeta, + hash: transactionHash, + }; + + const confirmationPromise = this.#waitForTransactionEvent( + pendingTransactionTracker, + transactionUpdated, + transactionHash, + ); + + pendingTransactionTracker.addTransactionToPoll(transactionUpdated); + + await confirmationPromise; + results.push({ transactionHash }); + } catch (error) { + log('Batch transaction failed', { transaction, error }); + pendingTransactionTracker.stop(); + throw rpcErrors.internal(`Failed to publish batch transaction`); + } + } + log('Sequential publish batch hook completed', { results }); + pendingTransactionTracker.stop(); + + return { results }; + } + + /** + * Waits for a transaction event (confirmed, failed, or dropped) and resolves/rejects accordingly. + * + * @param pendingTransactionTracker - The tracker instance to subscribe to events. + * @param transactionMeta - The transaction metadata. + * @param transactionHash - The hash of the transaction. + * @returns A promise that resolves when the transaction is confirmed or rejects if it fails or is dropped. + */ + async #waitForTransactionEvent( + pendingTransactionTracker: PendingTransactionTracker, + transactionMeta: TransactionMeta, + transactionHash: string, + ): Promise { + return new Promise((resolve, reject) => { + const onConfirmed = this.#onConfirmedHandler({ + transactionMeta, + transactionHash, + resolve, + pendingTransactionTracker, + }); + + const onFailedOrDropped = this.#onFailedOrDroppedHandler({ + transactionMeta, + transactionHash, + reject, + pendingTransactionTracker, + }); + + this.#addListeners({ + pendingTransactionTracker, + onConfirmed, + onFailedOrDropped, + }); + }); + } + + #onConfirmedHandler({ + transactionMeta, + transactionHash, + resolve, + pendingTransactionTracker, + }: OnConfirmedHandlerOptions): (txMeta: TransactionMeta) => void { + return (txMeta) => { + if (txMeta.id === transactionMeta.id) { + log('Transaction confirmed', { transactionHash }); + this.#removeListeners({ + pendingTransactionTracker, + }); + resolve(txMeta); + } + }; + } + + #onFailedOrDroppedHandler({ + transactionMeta, + transactionHash, + reject, + pendingTransactionTracker, + }: OnFailedOrDroppedHandlerOptions): ( + txMeta: TransactionMeta, + error?: Error, + ) => void { + return (txMeta, error) => { + if (txMeta.id === transactionMeta.id) { + log('Transaction failed or dropped', { transactionHash, error }); + this.#removeListeners({ + pendingTransactionTracker, + }); + reject(new Error(`Transaction ${transactionHash} failed or dropped.`)); + } + }; + } + + #addListeners({ + pendingTransactionTracker, + onConfirmed, + onFailedOrDropped, + }: TrackerListenersOptions): void { + pendingTransactionTracker.hub.on('transaction-confirmed', onConfirmed); + pendingTransactionTracker.hub.on('transaction-dropped', onFailedOrDropped); + pendingTransactionTracker.hub.on('transaction-failed', onFailedOrDropped); + } + + #removeListeners({ + pendingTransactionTracker, + }: { + pendingTransactionTracker: PendingTransactionTracker; + }): void { + pendingTransactionTracker.hub.removeAllListeners('transaction-confirmed'); + pendingTransactionTracker.hub.removeAllListeners('transaction-dropped'); + pendingTransactionTracker.hub.removeAllListeners('transaction-failed'); + } +} diff --git a/packages/transaction-controller/src/utils/batch.test.ts b/packages/transaction-controller/src/utils/batch.test.ts index a32aaedb478..ec8b9fd0447 100644 --- a/packages/transaction-controller/src/utils/batch.test.ts +++ b/packages/transaction-controller/src/utils/batch.test.ts @@ -24,6 +24,7 @@ import { TransactionType, } from '..'; import { flushPromises } from '../../../../tests/helpers'; +import { SequentialPublishBatchHook } from '../hooks/SequentialPublishBatchHook'; import type { PublishBatchHook } from '../types'; jest.mock('./eip7702'); @@ -35,6 +36,8 @@ jest.mock('./validation', () => ({ validateBatchRequest: jest.fn(), })); +jest.mock('../hooks/SequentialPublishBatchHook'); + type AddBatchTransactionOptions = Parameters[0]; const CHAIN_ID_MOCK = '0x123'; @@ -77,6 +80,9 @@ describe('Batch Utils', () => { const getEIP7702SupportedChainsMock = jest.mocked(getEIP7702SupportedChains); const validateBatchRequestMock = jest.mocked(validateBatchRequest); const determineTransactionTypeMock = jest.mocked(determineTransactionType); + const sequentialPublishBatchHookMock = jest.mocked( + SequentialPublishBatchHook, + ); const isAccountUpgradedToEIP7702Mock = jest.mocked( isAccountUpgradedToEIP7702, @@ -103,6 +109,14 @@ describe('Batch Utils', () => { AddBatchTransactionOptions['updateTransaction'] >; + let publishTransactionMock: jest.MockedFn< + AddBatchTransactionOptions['publishTransaction'] + >; + + let getPendingTransactionTracker: jest.MockedFn< + AddBatchTransactionOptions['getPendingTransactionTrackerByChainId'] + >; + let request: AddBatchTransactionOptions; beforeEach(() => { @@ -110,6 +124,8 @@ describe('Batch Utils', () => { addTransactionMock = jest.fn(); getChainIdMock = jest.fn(); updateTransactionMock = jest.fn(); + publishTransactionMock = jest.fn(); + getPendingTransactionTracker = jest.fn(); determineTransactionTypeMock.mockResolvedValue({ type: TransactionType.simpleSend, @@ -148,6 +164,8 @@ describe('Batch Utils', () => { ], }, updateTransaction: updateTransactionMock, + publishTransaction: publishTransactionMock, + getPendingTransactionTrackerByChainId: getPendingTransactionTracker, }; }); @@ -967,15 +985,6 @@ describe('Batch Utils', () => { ); }); - it('throws if no publish batch hook', async () => { - await expect( - addTransactionBatch({ - ...request, - request: { ...request.request, useHook: true }, - }), - ).rejects.toThrow(rpcErrors.internal('No publish batch hook provided')); - }); - it('rejects individual publish hooks if batch hook throws', async () => { const publishBatchHook: jest.MockedFn = jest.fn(); @@ -1078,6 +1087,146 @@ describe('Batch Utils', () => { await expect(publishHookPromise1).rejects.toThrow(ERROR_MESSAGE_MOCK); }); }); + + describe('with sequential publish batch hook', () => { + let sequentialPublishBatchHook: jest.MockedFn; + + beforeEach(() => { + sequentialPublishBatchHook = jest.fn(); + + addTransactionMock + .mockResolvedValueOnce({ + transactionMeta: { + ...TRANSACTION_META_MOCK, + id: TRANSACTION_ID_MOCK, + }, + result: Promise.resolve(''), + }) + .mockResolvedValueOnce({ + transactionMeta: { + ...TRANSACTION_META_MOCK, + id: TRANSACTION_ID_2_MOCK, + }, + result: Promise.resolve(''), + }); + }); + + const setupSequentialPublishBatchHookMock = ( + hookImplementation: () => PublishBatchHook | undefined, + ) => { + sequentialPublishBatchHookMock.mockReturnValue({ + getHook: hookImplementation, + } as unknown as SequentialPublishBatchHook); + }; + + const executePublishHooks = async () => { + const publishHooks = addTransactionMock.mock.calls.map( + ([, options]) => options.publishHook, + ); + + publishHooks[0]?.( + TRANSACTION_META_MOCK, + TRANSACTION_SIGNATURE_MOCK, + ).catch(() => { + // Intentionally empty + }); + + publishHooks[1]?.( + TRANSACTION_META_MOCK, + TRANSACTION_SIGNATURE_2_MOCK, + ).catch(() => { + // Intentionally empty + }); + + await flushPromises(); + }; + + it('calls sequentialPublishBatchHook when publishBatchHook is undefined', async () => { + sequentialPublishBatchHook.mockResolvedValueOnce({ + results: [ + { + transactionHash: TRANSACTION_HASH_MOCK, + }, + { + transactionHash: TRANSACTION_HASH_2_MOCK, + }, + ], + }); + + setupSequentialPublishBatchHookMock(() => sequentialPublishBatchHook); + + addTransactionBatch({ + ...request, + publishBatchHook: undefined, + request: { ...request.request, useHook: true }, + }).catch(() => { + // Intentionally empty + }); + + await flushPromises(); + await executePublishHooks(); + + expect(sequentialPublishBatchHookMock).toHaveBeenCalledTimes(1); + expect(sequentialPublishBatchHook).toHaveBeenCalledTimes(1); + expect(sequentialPublishBatchHook).toHaveBeenCalledWith({ + from: FROM_MOCK, + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions: [ + expect.objectContaining({ + id: TRANSACTION_ID_MOCK, + params: { data: DATA_MOCK, to: TO_MOCK, value: VALUE_MOCK }, + signedTx: TRANSACTION_SIGNATURE_MOCK, + }), + expect.objectContaining({ + id: TRANSACTION_ID_2_MOCK, + params: { data: DATA_MOCK, to: TO_MOCK, value: VALUE_MOCK }, + signedTx: TRANSACTION_SIGNATURE_2_MOCK, + }), + ], + }); + }); + + it('throws if sequentialPublishBatchHook does not return a result', async () => { + const publishBatchHookMock: jest.MockedFn = jest.fn(); + publishBatchHookMock.mockResolvedValueOnce(undefined); + setupSequentialPublishBatchHookMock(() => publishBatchHookMock); + + const resultPromise = addTransactionBatch({ + ...request, + publishBatchHook: undefined, + request: { ...request.request, useHook: true }, + }); + + resultPromise.catch(() => { + // Intentionally empty + }); + + await flushPromises(); + await executePublishHooks(); + + await expect(resultPromise).rejects.toThrow( + 'Publish batch hook did not return a result', + ); + await flushPromises(); + expect(sequentialPublishBatchHookMock).toHaveBeenCalledTimes(1); + }); + + it('handles individual transaction failures when using sequentialPublishBatchHook', async () => { + setupSequentialPublishBatchHookMock(() => { + throw new Error('Test error'); + }); + + await expect( + addTransactionBatch({ + ...request, + publishBatchHook: undefined, + request: { ...request.request, useHook: true }, + }), + ).rejects.toThrow('Test error'); + + expect(sequentialPublishBatchHookMock).toHaveBeenCalledTimes(1); + }); + }); }); describe('isAtomicBatchSupported', () => { diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index 84af05b244c..aa7d8a0c871 100644 --- a/packages/transaction-controller/src/utils/batch.ts +++ b/packages/transaction-controller/src/utils/batch.ts @@ -23,7 +23,9 @@ import { type TransactionControllerMessenger, type TransactionMeta, } from '..'; +import type { PendingTransactionTracker } from '../helpers/PendingTransactionTracker'; import { CollectPublishHook } from '../hooks/CollectPublishHook'; +import { SequentialPublishBatchHook } from '../hooks/SequentialPublishBatchHook'; import { projectLogger } from '../logger'; import type { NestedTransactionMetadata, @@ -58,6 +60,13 @@ type AddTransactionBatchRequest = { options: { transactionId: string }, callback: (transactionMeta: TransactionMeta) => void, ) => void; + publishTransaction: ( + _ethQuery: EthQuery, + transactionMeta: TransactionMeta, + ) => Promise; + getPendingTransactionTrackerByChainId: ( + networkClientId: string, + ) => PendingTransactionTracker; }; type IsAtomicBatchSupportedRequestInternal = { @@ -173,7 +182,7 @@ export async function isAtomicBatchSupported( } /** - * Generate a tranasction batch ID. + * Generate a transaction batch ID. * * @returns A unique batch ID as a hexadecimal string. */ @@ -349,7 +358,8 @@ async function addTransactionBatchWith7702( async function addTransactionBatchWithHook( request: AddTransactionBatchRequest, ): Promise { - const { publishBatchHook, request: userRequest } = request; + const { publishBatchHook: requestPublishBatchHook, request: userRequest } = + request; const { from, @@ -359,10 +369,16 @@ async function addTransactionBatchWithHook( log('Adding transaction batch using hook', userRequest); - if (!publishBatchHook) { - log('No publish batch hook provided'); - throw new Error('No publish batch hook provided'); - } + const sequentialPublishBatchHook = new SequentialPublishBatchHook({ + publishTransaction: request.publishTransaction, + getTransaction: request.getTransaction, + getEthQuery: request.getEthQuery, + getPendingTransactionTrackerByChainId: + request.getPendingTransactionTrackerByChainId, + }); + + const publishBatchHook = + requestPublishBatchHook ?? sequentialPublishBatchHook.getHook(); const batchId = generateBatchId(); const transactionCount = nestedTransactions.length;