From 26c1a4b93478b003eee1b2f349fe0f5b8003c56f Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 6 Jul 2026 21:29:04 -0400 Subject: [PATCH 1/2] fix: fail cancelled smart transactions through the standard failure path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the relay cancels a smart transaction (e.g. FAILED_GAS_TOO_LOW), the smart-transactions-controller marked the associated regular transaction as failed via `TransactionController:updateTransaction`, which only patches state and does not emit any transaction lifecycle events. Consumers that react to `transactionFailed`/`transactionStatusUpdated` — notably the bridge status controller and transaction metrics — were therefore never notified, leaving cancelled smart transactions (such as bridges) stuck as pending indefinitely. - transaction-controller: add a public `failTransaction(transactionId, error)` method and `TransactionController:failTransaction` messenger action that fails a transaction through the internal fail path, emitting `transactionFailed`, `transactionStatusUpdated`, and `transactionFinished`. - smart-transactions-controller: use `failTransaction` instead of `updateTransaction` when marking regular transactions as failed. --- .../CHANGELOG.md | 6 ++ .../src/SmartTransactionsController.test.ts | 84 +++++++--------- .../src/SmartTransactionsController.ts | 14 +-- .../src/utils.test.ts | 98 ++++++++----------- .../src/utils.ts | 29 +++--- packages/transaction-controller/CHANGELOG.md | 6 ++ ...ansactionController-method-action-types.ts | 20 ++++ .../src/TransactionController.test.ts | 71 ++++++++++++++ .../src/TransactionController.ts | 20 ++++ packages/transaction-controller/src/index.ts | 1 + 10 files changed, 222 insertions(+), 127 deletions(-) diff --git a/packages/smart-transactions-controller/CHANGELOG.md b/packages/smart-transactions-controller/CHANGELOG.md index d86fa78667..c0553d1104 100644 --- a/packages/smart-transactions-controller/CHANGELOG.md +++ b/packages/smart-transactions-controller/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +### Fixed + +- Fail the associated regular transaction via the new `TransactionController:failTransaction` action instead of `TransactionController:updateTransaction` when a smart transaction is cancelled ([#0000](https://github.com/MetaMask/core/pull/0000)) + - `updateTransaction` only patches state and does not emit transaction lifecycle events, so consumers that react to `transactionFailed`/`transactionStatusUpdated` (e.g. the bridge status controller and metrics) were never notified, leaving cancelled smart transactions — such as bridges — stuck as pending indefinitely. + - This requires consumers to grant the smart transactions controller messenger access to the `TransactionController:failTransaction` action (previously `TransactionController:updateTransaction`). + ## [24.2.4] ### Changed diff --git a/packages/smart-transactions-controller/src/SmartTransactionsController.test.ts b/packages/smart-transactions-controller/src/SmartTransactionsController.test.ts index 22bb39537e..6d921c3c25 100644 --- a/packages/smart-transactions-controller/src/SmartTransactionsController.test.ts +++ b/packages/smart-transactions-controller/src/SmartTransactionsController.test.ts @@ -17,9 +17,9 @@ import { TransactionType, } from '@metamask/transaction-controller'; import type { + TransactionControllerFailTransactionAction, TransactionControllerGetNonceLockAction, TransactionControllerGetTransactionsAction, - TransactionControllerUpdateTransactionAction, TransactionParams, } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; @@ -362,7 +362,7 @@ describe('SmartTransactionsController', () => { jest.fn(), ); rootMessenger.registerActionHandler( - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', jest.fn(), ); rootMessenger.registerActionHandler( @@ -391,7 +391,7 @@ describe('SmartTransactionsController', () => { 'RemoteFeatureFlagController:getState', 'TransactionController:getNonceLock', 'TransactionController:getTransactions', - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', ], events: [ 'NetworkController:stateChange', @@ -464,7 +464,7 @@ describe('SmartTransactionsController', () => { jest.fn(), ); rootMessenger.registerActionHandler( - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', jest.fn(), ); rootMessenger.registerActionHandler( @@ -495,7 +495,7 @@ describe('SmartTransactionsController', () => { 'RemoteFeatureFlagController:getState', 'TransactionController:getNonceLock', 'TransactionController:getTransactions', - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', ], events: [ 'NetworkController:stateChange', @@ -564,7 +564,7 @@ describe('SmartTransactionsController', () => { jest.fn(), ); rootMessenger.registerActionHandler( - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', jest.fn(), ); rootMessenger.registerActionHandler( @@ -593,7 +593,7 @@ describe('SmartTransactionsController', () => { 'RemoteFeatureFlagController:getState', 'TransactionController:getNonceLock', 'TransactionController:getTransactions', - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', ], events: [ 'NetworkController:stateChange', @@ -673,7 +673,7 @@ describe('SmartTransactionsController', () => { jest.fn(), ); rootMessenger.registerActionHandler( - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', jest.fn(), ); rootMessenger.registerActionHandler( @@ -698,7 +698,7 @@ describe('SmartTransactionsController', () => { 'RemoteFeatureFlagController:getState', 'TransactionController:getNonceLock', 'TransactionController:getTransactions', - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', ], events: [ 'NetworkController:stateChange', @@ -777,7 +777,7 @@ describe('SmartTransactionsController', () => { jest.fn(), ); rootMessenger.registerActionHandler( - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', jest.fn(), ); rootMessenger.registerActionHandler( @@ -811,7 +811,7 @@ describe('SmartTransactionsController', () => { 'RemoteFeatureFlagController:getState', 'TransactionController:getNonceLock', 'TransactionController:getTransactions', - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', ], events: [ 'NetworkController:stateChange', @@ -1833,8 +1833,8 @@ describe('SmartTransactionsController', () => { ); }); - it('calls updateTransaction when smart transaction is cancelled and returnTxHashAsap is true', async () => { - const mockUpdateTransaction = jest.fn(); + it('fails the regular transaction when smart transaction is cancelled and returnTxHashAsap is true', async () => { + const mockFailTransaction = jest.fn(); const defaultState = getDefaultSmartTransactionsControllerState(); const pendingStx = createStateAfterPending(); await withController( @@ -1860,7 +1860,7 @@ describe('SmartTransactionsController', () => { }, }, }, - updateTransaction: mockUpdateTransaction, + failTransaction: mockFailTransaction, getTransactions: () => [ { id: 'test-tx-id', @@ -1883,29 +1883,19 @@ describe('SmartTransactionsController', () => { controller.updateSmartTransaction(smartTransaction); - expect(mockUpdateTransaction).toHaveBeenCalledWith( - { - id: 'test-tx-id', - status: TransactionStatus.failed, - chainId: '0x1', - time: 123, - txParams: { - from: '0x123', - }, - networkClientId: NetworkType.mainnet, - error: { - message: 'Smart transaction failed with status: cancelled', - name: 'SmartTransactionFailed', - }, - }, - 'Smart transaction status: cancelled', + expect(mockFailTransaction).toHaveBeenCalledWith( + 'test-tx-id', + expect.objectContaining({ + name: 'SmartTransactionFailed', + message: 'Smart transaction failed with status: cancelled', + }), ); }, ); }); - it('does not call updateTransaction when smart transaction is cancelled but returnTxHashAsap is false', async () => { - const mockUpdateTransaction = jest.fn(); + it('does not fail the transaction when smart transaction is cancelled but returnTxHashAsap is false', async () => { + const mockFailTransaction = jest.fn(); await withController( { remoteFeatureFlags: { @@ -1915,7 +1905,7 @@ describe('SmartTransactionsController', () => { }, }, }, - updateTransaction: mockUpdateTransaction, + failTransaction: mockFailTransaction, getTransactions: () => [ { id: 'test-tx-id', @@ -1938,13 +1928,13 @@ describe('SmartTransactionsController', () => { controller.updateSmartTransaction(smartTransaction); - expect(mockUpdateTransaction).not.toHaveBeenCalled(); + expect(mockFailTransaction).not.toHaveBeenCalled(); }, ); }); - it('does not call updateTransaction when transaction is not found in regular transactions', async () => { - const mockUpdateTransaction = jest.fn(); + it('does not fail the transaction when transaction is not found in regular transactions', async () => { + const mockFailTransaction = jest.fn(); await withController( { @@ -1955,7 +1945,7 @@ describe('SmartTransactionsController', () => { }, }, }, - updateTransaction: mockUpdateTransaction, + failTransaction: mockFailTransaction, getTransactions: () => [], }, async ({ controller }) => { @@ -1967,13 +1957,13 @@ describe('SmartTransactionsController', () => { controller.updateSmartTransaction(smartTransaction); - expect(mockUpdateTransaction).not.toHaveBeenCalled(); + expect(mockFailTransaction).not.toHaveBeenCalled(); }, ); }); - it('does not call updateTransaction for non-cancelled transactions', async () => { - const mockUpdateTransaction = jest.fn(); + it('does not fail the transaction for non-cancelled transactions', async () => { + const mockFailTransaction = jest.fn(); await withController( { remoteFeatureFlags: { @@ -1983,7 +1973,7 @@ describe('SmartTransactionsController', () => { }, }, }, - updateTransaction: mockUpdateTransaction, + failTransaction: mockFailTransaction, getTransactions: () => [ { id: 'test-tx-id', @@ -2006,7 +1996,7 @@ describe('SmartTransactionsController', () => { controller.updateSmartTransaction(smartTransaction); - expect(mockUpdateTransaction).not.toHaveBeenCalled(); + expect(mockFailTransaction).not.toHaveBeenCalled(); }, ); }); @@ -3184,7 +3174,7 @@ type WithControllerOptions = { >; getNonceLock?: TransactionControllerGetNonceLockAction['handler']; getTransactions?: TransactionControllerGetTransactionsAction['handler']; - updateTransaction?: TransactionControllerUpdateTransactionAction['handler']; + failTransaction?: TransactionControllerFailTransactionAction['handler']; remoteFeatureFlags?: { smartTransactionsNetworks?: Record; }; @@ -3215,7 +3205,7 @@ async function withController( releaseLock: jest.fn(), }), getTransactions = jest.fn(), - updateTransaction = jest.fn(), + failTransaction = jest.fn(), remoteFeatureFlags = {}, bearerToken, } = rest; @@ -3294,8 +3284,8 @@ async function withController( getTransactions, ); rootMessenger.registerActionHandler( - 'TransactionController:updateTransaction', - updateTransaction, + 'TransactionController:failTransaction', + failTransaction, ); rootMessenger.registerActionHandler( 'RemoteFeatureFlagController:getState', @@ -3327,7 +3317,7 @@ async function withController( 'RemoteFeatureFlagController:getState', 'TransactionController:getNonceLock', 'TransactionController:getTransactions', - 'TransactionController:updateTransaction', + 'TransactionController:failTransaction', ], events: [ 'NetworkController:stateChange', diff --git a/packages/smart-transactions-controller/src/SmartTransactionsController.ts b/packages/smart-transactions-controller/src/SmartTransactionsController.ts index 5b96f5bdbd..5a7756fa06 100644 --- a/packages/smart-transactions-controller/src/SmartTransactionsController.ts +++ b/packages/smart-transactions-controller/src/SmartTransactionsController.ts @@ -26,9 +26,9 @@ import type { RemoteFeatureFlagControllerStateChangeEvent, } from '@metamask/remote-feature-flag-controller'; import type { + TransactionControllerFailTransactionAction, TransactionControllerGetNonceLockAction, TransactionControllerGetTransactionsAction, - TransactionControllerUpdateTransactionAction, TransactionMeta, TransactionParams, } from '@metamask/transaction-controller'; @@ -192,9 +192,9 @@ type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetStateAction | RemoteFeatureFlagControllerGetStateAction + | TransactionControllerFailTransactionAction | TransactionControllerGetNonceLockAction - | TransactionControllerGetTransactionsAction - | TransactionControllerUpdateTransactionAction; + | TransactionControllerGetTransactionsAction; export type SmartTransactionsControllerStateChangeEvent = ControllerStateChangeEvent< @@ -692,11 +692,11 @@ export class SmartTransactionsController extends StaticIntervalPollingController smartTransaction: nextSmartTransaction, getRegularTransactions: () => this.messenger.call('TransactionController:getTransactions'), - updateTransaction: (transactionMeta: TransactionMeta, note: string) => + failTransaction: (transactionId: string, error: Error) => this.messenger.call( - 'TransactionController:updateTransaction', - transactionMeta, - note, + 'TransactionController:failTransaction', + transactionId, + error, ), }); } diff --git a/packages/smart-transactions-controller/src/utils.test.ts b/packages/smart-transactions-controller/src/utils.test.ts index b04d51db91..db4b476610 100644 --- a/packages/smart-transactions-controller/src/utils.test.ts +++ b/packages/smart-transactions-controller/src/utils.test.ts @@ -670,32 +670,28 @@ describe('src/utils.js', () => { networkClientId: NetworkType.mainnet, }; - it('updates transaction with failed status and error message', () => { - const updateTransactionMock = jest.fn(); + it('fails the transaction with a SmartTransactionFailed error', () => { + const failTransactionMock = jest.fn(); utils.markRegularTransactionsAsFailed({ smartTransaction: createSmartTransaction( SmartTransactionStatuses.CANCELLED, ), getRegularTransactions: () => [mockTransaction], - updateTransaction: updateTransactionMock, + failTransaction: failTransactionMock, }); - expect(updateTransactionMock).toHaveBeenCalledWith( - { - ...mockTransaction, - status: TransactionStatus.failed, - error: { - name: 'SmartTransactionFailed', - message: 'Smart transaction failed with status: cancelled', - }, - }, - 'Smart transaction status: cancelled', + expect(failTransactionMock).toHaveBeenCalledWith( + '123', + expect.objectContaining({ + name: 'SmartTransactionFailed', + message: 'Smart transaction failed with status: cancelled', + }), ); }); it('includes originalTransactionStatus in error message when available', () => { - const updateTransactionMock = jest.fn(); + const failTransactionMock = jest.fn(); const smartTransaction = { ...createSmartTransaction(SmartTransactionStatuses.CANCELLED), statusMetadata: { @@ -712,25 +708,21 @@ describe('src/utils.js', () => { utils.markRegularTransactionsAsFailed({ smartTransaction, getRegularTransactions: () => [mockTransaction], - updateTransaction: updateTransactionMock, + failTransaction: failTransactionMock, }); - expect(updateTransactionMock).toHaveBeenCalledWith( - { - ...mockTransaction, - status: TransactionStatus.failed, - error: { - name: 'SmartTransactionFailed', - message: - 'Smart transaction failed with status: cancelled, originalTransactionStatus: FAILED_WOULD_REVERT', - }, - }, - 'Smart transaction status: cancelled', + expect(failTransactionMock).toHaveBeenCalledWith( + '123', + expect.objectContaining({ + name: 'SmartTransactionFailed', + message: + 'Smart transaction failed with status: cancelled, originalTransactionStatus: FAILED_WOULD_REVERT', + }), ); }); it('throws error if original transaction cannot be found', () => { - const updateTransactionMock = jest.fn(); + const failTransactionMock = jest.fn(); const getRegularTransactionsMock = jest.fn(() => []); expect(() => @@ -739,15 +731,15 @@ describe('src/utils.js', () => { SmartTransactionStatuses.CANCELLED, ), getRegularTransactions: getRegularTransactionsMock, - updateTransaction: updateTransactionMock, + failTransaction: failTransactionMock, }), ).toThrow('Cannot find regular transaction to mark it as failed'); - expect(updateTransactionMock).not.toHaveBeenCalled(); + expect(failTransactionMock).not.toHaveBeenCalled(); }); - it('does not update transaction if status is already failed', () => { - const updateTransactionMock = jest.fn(); + it('does not fail the transaction if status is already failed', () => { + const failTransactionMock = jest.fn(); const failedTransaction = { ...mockTransaction, status: TransactionStatus.failed, @@ -762,14 +754,14 @@ describe('src/utils.js', () => { SmartTransactionStatuses.CANCELLED, ), getRegularTransactions: () => [failedTransaction], - updateTransaction: updateTransactionMock, + failTransaction: failTransactionMock, }); - expect(updateTransactionMock).not.toHaveBeenCalled(); + expect(failTransactionMock).not.toHaveBeenCalled(); }); - it('marks multiple transactions as failed when txHashes match', () => { - const updateTransactionMock = jest.fn(); + it('fails multiple transactions when txHashes match', () => { + const failTransactionMock = jest.fn(); const transaction1: TransactionMeta = { ...mockTransaction, id: '456', @@ -788,31 +780,23 @@ describe('src/utils.js', () => { utils.markRegularTransactionsAsFailed({ smartTransaction, getRegularTransactions: () => [transaction1, transaction2], - updateTransaction: updateTransactionMock, + failTransaction: failTransactionMock, }); - expect(updateTransactionMock).toHaveBeenCalledTimes(2); - expect(updateTransactionMock).toHaveBeenCalledWith( - { - ...transaction1, - status: TransactionStatus.failed, - error: { - name: 'SmartTransactionFailed', - message: 'Smart transaction failed with status: cancelled', - }, - }, - 'Smart transaction status: cancelled', + expect(failTransactionMock).toHaveBeenCalledTimes(2); + expect(failTransactionMock).toHaveBeenCalledWith( + '456', + expect.objectContaining({ + name: 'SmartTransactionFailed', + message: 'Smart transaction failed with status: cancelled', + }), ); - expect(updateTransactionMock).toHaveBeenCalledWith( - { - ...transaction2, - status: TransactionStatus.failed, - error: { - name: 'SmartTransactionFailed', - message: 'Smart transaction failed with status: cancelled', - }, - }, - 'Smart transaction status: cancelled', + expect(failTransactionMock).toHaveBeenCalledWith( + '789', + expect.objectContaining({ + name: 'SmartTransactionFailed', + message: 'Smart transaction failed with status: cancelled', + }), ); }); }); diff --git a/packages/smart-transactions-controller/src/utils.ts b/packages/smart-transactions-controller/src/utils.ts index 533c732b4d..936cd3406e 100644 --- a/packages/smart-transactions-controller/src/utils.ts +++ b/packages/smart-transactions-controller/src/utils.ts @@ -2,9 +2,8 @@ import { arrayify, hexlify } from '@ethersproject/bytes'; import { keccak256 } from '@ethersproject/keccak256'; import { parse } from '@ethersproject/transactions'; import type { + TransactionControllerFailTransactionAction, TransactionControllerGetTransactionsAction, - TransactionControllerUpdateTransactionAction, - TransactionMeta, } from '@metamask/transaction-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; @@ -288,11 +287,11 @@ export const shouldMarkRegularTransactionsAsFailed = ({ export const markRegularTransactionsAsFailed = ({ smartTransaction, getRegularTransactions, - updateTransaction, + failTransaction, }: { smartTransaction: SmartTransaction; getRegularTransactions: TransactionControllerGetTransactionsAction['handler']; - updateTransaction: TransactionControllerUpdateTransactionAction['handler']; + failTransaction: TransactionControllerFailTransactionAction['handler']; }) => { const { transactionId, status, statusMetadata, txHashes } = smartTransaction; const originalTransactionStatus = statusMetadata?.originalTransactionStatus; @@ -313,18 +312,16 @@ export const markRegularTransactionsAsFailed = ({ if (tx.status === TransactionStatus.failed) { continue; // Already marked as failed. } - const updatedTransaction: TransactionMeta = { - ...tx, - status: TransactionStatus.failed, - error: { - name: 'SmartTransactionFailed', - message: errorMessage, - }, - }; - updateTransaction( - updatedTransaction, - `Smart transaction status: ${status}`, - ); + const error = new Error(errorMessage); + error.name = 'SmartTransactionFailed'; + + // Fail via the TransactionController's `failTransaction` action rather than + // `updateTransaction` so the transaction lifecycle events + // (`transactionFailed`, `transactionStatusUpdated`, `transactionFinished`) + // are emitted. Downstream subscribers such as the bridge status controller + // and metrics rely on these events; a plain state update leaves e.g. bridge + // transactions stuck as pending forever. + failTransaction(tx.id, error); } }; diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 17809b45e5..728bd7c21e 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `failTransaction` method and corresponding `TransactionController:failTransaction` messenger action ([#0000](https://github.com/MetaMask/core/pull/0000)) + - Marks a transaction as failed through the standard failure path, emitting the `transactionFailed`, `transactionStatusUpdated`, and `transactionFinished` events so downstream subscribers (e.g. bridge status, metrics) are notified. + - Intended for callers that finalize a transaction out-of-band, such as the smart transactions controller when the relay cancels a smart transaction. + ### Changed - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) diff --git a/packages/transaction-controller/src/TransactionController-method-action-types.ts b/packages/transaction-controller/src/TransactionController-method-action-types.ts index 8090c91ffb..ab865d28b1 100644 --- a/packages/transaction-controller/src/TransactionController-method-action-types.ts +++ b/packages/transaction-controller/src/TransactionController-method-action-types.ts @@ -134,6 +134,25 @@ export type TransactionControllerUpdateTransactionAction = { handler: TransactionController['updateTransaction']; }; +/** + * Mark a transaction as failed, transitioning it through the standard failure + * path. + * + * Unlike `updateTransaction`, this emits the transaction lifecycle events + * (`transactionFailed`, `transactionStatusUpdated`, `transactionFinished`), so + * downstream subscribers such as the bridge status controller and metrics are + * notified. Intended for callers that finalize a transaction out-of-band, for + * example the smart transactions controller when the relay cancels a smart + * transaction that never landed on chain. + * + * @param transactionId - The ID of the transaction to mark as failed. + * @param error - The error describing why the transaction failed. + */ +export type TransactionControllerFailTransactionAction = { + type: `TransactionController:failTransaction`; + handler: TransactionController['failTransaction']; +}; + /** * Update the security alert response for a transaction. * @@ -422,6 +441,7 @@ export type TransactionControllerMethodActions = | TransactionControllerEstimateGasBatchAction | TransactionControllerEstimateGasBufferedAction | TransactionControllerUpdateTransactionAction + | TransactionControllerFailTransactionAction | TransactionControllerUpdateSecurityAlertResponseAction | TransactionControllerWipeTransactionsAction | TransactionControllerConfirmExternalTransactionAction diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 8a91dc6b17..14fe25b561 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -6060,6 +6060,77 @@ describe('TransactionController', () => { }); }); + describe('failTransaction', () => { + const submittedTransactionMock = { + ...TRANSACTION_META_MOCK, + status: TransactionStatus.submitted as const, + } as unknown as TransactionMeta; + + it('marks the transaction as failed with the provided error', () => { + const { controller } = setupController({ + options: { + state: { + transactions: [submittedTransactionMock], + }, + }, + }); + + controller.failTransaction( + submittedTransactionMock.id, + new Error('Test failure'), + ); + + expect(controller.state.transactions[0].status).toBe( + TransactionStatus.failed, + ); + expect(controller.state.transactions[0].error?.message).toBe( + 'Test failure', + ); + }); + + it('publishes the failure lifecycle events', () => { + const failedListener = jest.fn(); + const statusUpdatedListener = jest.fn(); + const finishedListener = jest.fn(); + const { controller, messenger } = setupController({ + options: { + state: { + transactions: [submittedTransactionMock], + }, + }, + }); + messenger.subscribe( + 'TransactionController:transactionFailed', + failedListener, + ); + messenger.subscribe( + 'TransactionController:transactionStatusUpdated', + statusUpdatedListener, + ); + messenger.subscribe( + 'TransactionController:transactionFinished', + finishedListener, + ); + + controller.failTransaction( + submittedTransactionMock.id, + new Error('Test failure'), + ); + + expect(failedListener).toHaveBeenCalledTimes(1); + expect(statusUpdatedListener).toHaveBeenCalledTimes(1); + expect(finishedListener).toHaveBeenCalledTimes(1); + }); + + it('throws if the transaction does not exist', () => { + const { controller } = setupController(); + + expect(() => + controller.failTransaction('missing-id', new Error('Test failure')), + ).toThrow('No transaction found with id missing-id'); + }); + }); + describe('updateSecurityAlertResponse', () => { it('add securityAlertResponse to transaction meta', async () => { const transactionMeta = TRANSACTION_META_MOCK; diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 7d94dc859a..93a2084b7f 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -652,6 +652,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'estimateGasBatch', 'estimateGasBuffered', 'estimateGasFee', + 'failTransaction', 'getGasFeeTokens', 'getLayer1GasFee', 'getNonceLock', @@ -1573,6 +1574,25 @@ export class TransactionController extends BaseController< log('Transaction updated', { transactionId, note }); } + /** + * Mark a transaction as failed, transitioning it through the standard failure + * path. + * + * Unlike `updateTransaction`, this emits the transaction lifecycle events + * (`transactionFailed`, `transactionStatusUpdated`, `transactionFinished`), so + * downstream subscribers such as the bridge status controller and metrics are + * notified. Intended for callers that finalize a transaction out-of-band, for + * example the smart transactions controller when the relay cancels a smart + * transaction that never landed on chain. + * + * @param transactionId - The ID of the transaction to mark as failed. + * @param error - The error describing why the transaction failed. + */ + failTransaction(transactionId: string, error: Error): void { + const transactionMeta = this.#getTransactionOrThrow(transactionId); + this.#failTransaction(transactionMeta, error); + } + /** * Update the security alert response for a transaction. * diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index 3439ba812d..6c4b08d21f 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -45,6 +45,7 @@ export type { TransactionControllerSetTransactionActiveAction, TransactionControllerApproveTransactionsWithSameNonceAction, TransactionControllerEstimateGasFeeAction, + TransactionControllerFailTransactionAction, TransactionControllerGetLayer1GasFeeAction, TransactionControllerClearUnapprovedTransactionsAction, TransactionControllerAbortTransactionSigningAction, From c72d709fba578e65921af248f9cf75e78d8b3947 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 6 Jul 2026 21:32:10 -0400 Subject: [PATCH 2/2] docs: reference PR number in changelog entries --- packages/smart-transactions-controller/CHANGELOG.md | 2 +- packages/transaction-controller/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/smart-transactions-controller/CHANGELOG.md b/packages/smart-transactions-controller/CHANGELOG.md index c0553d1104..bd87c63097 100644 --- a/packages/smart-transactions-controller/CHANGELOG.md +++ b/packages/smart-transactions-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fail the associated regular transaction via the new `TransactionController:failTransaction` action instead of `TransactionController:updateTransaction` when a smart transaction is cancelled ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Fail the associated regular transaction via the new `TransactionController:failTransaction` action instead of `TransactionController:updateTransaction` when a smart transaction is cancelled ([#9400](https://github.com/MetaMask/core/pull/9400)) - `updateTransaction` only patches state and does not emit transaction lifecycle events, so consumers that react to `transactionFailed`/`transactionStatusUpdated` (e.g. the bridge status controller and metrics) were never notified, leaving cancelled smart transactions — such as bridges — stuck as pending indefinitely. - This requires consumers to grant the smart transactions controller messenger access to the `TransactionController:failTransaction` action (previously `TransactionController:updateTransaction`). diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 728bd7c21e..b0d2834166 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `failTransaction` method and corresponding `TransactionController:failTransaction` messenger action ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `failTransaction` method and corresponding `TransactionController:failTransaction` messenger action ([#9400](https://github.com/MetaMask/core/pull/9400)) - Marks a transaction as failed through the standard failure path, emitting the `transactionFailed`, `transactionStatusUpdated`, and `transactionFinished` events so downstream subscribers (e.g. bridge status, metrics) are notified. - Intended for callers that finalize a transaction out-of-band, such as the smart transactions controller when the relay cancels a smart transaction.