Skip to content

fix: fail cancelled smart transactions through the standard failure path#9400

Merged
cloudonshore merged 3 commits into
mainfrom
fix/stx-cancelled-tx-missing-status-event
Jul 7, 2026
Merged

fix: fail cancelled smart transactions through the standard failure path#9400
cloudonshore merged 3 commits into
mainfrom
fix/stx-cancelled-tx-missing-status-event

Conversation

@cloudonshore

@cloudonshore cloudonshore commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Explanation

When the transaction relay cancels a smart transaction (e.g. FAILED_GAS_TOO_LOW — the smart tx never lands on chain), SmartTransactionsController marks the associated regular transaction as failed via the TransactionController:updateTransaction action.

updateTransaction only patches state — it does not emit any transaction lifecycle events. As a result, consumers that react to transactionFailed / transactionStatusUpdated are never notified:

  • BridgeStatusController marks a bridge item failed only from the transactionStatusUpdated / transactionFailed event. Since that event never fires for a cancelled smart tx, the bridge history item stays PENDING forever — the transaction shows as failed, but the bridge UI remains stuck on "Pending".
  • Transaction metrics (Transaction Finalized) are likely under-reported for the same reason.

This was observed in production with bridge smart transactions repeatedly stuck as pending (source tx status: failed, bridge status.status: PENDING), while the relay's batchStatus reported minedTx: cancelled / cancellationReason: too_cheap.

Fix

Route the cancellation failure through the standard fail path so the lifecycle events fire.

  • transaction-controller: add a public failTransaction(transactionId, error) method and corresponding TransactionController:failTransaction messenger action. It fails the transaction through the internal #failTransaction, emitting transactionFailed, transactionStatusUpdated, and transactionFinished.
  • smart-transactions-controller: markRegularTransactionsAsFailed now calls failTransaction instead of updateTransaction.

References

  • Fixes the "smart transaction bridge stuck as pending" reports.

Changelog

@metamask/transaction-controller

  • ADDED: failTransaction method and TransactionController:failTransaction messenger action.

@metamask/smart-transactions-controller

  • FIXED: Fail the associated regular transaction via TransactionController:failTransaction (was updateTransaction) so transaction lifecycle events are emitted and downstream consumers no longer leave cancelled smart transactions stuck as pending.
    • Consumers must grant the smart-transactions-controller messenger access to TransactionController:failTransaction (previously TransactionController:updateTransaction).

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc) for new or updated code as appropriate
  • I've highlighted breaking changes using the "BREAKING" category above as appropriate

Note

Medium Risk
Touches core transaction lifecycle and messenger permissions for smart-transactions; behavior change is intentional but clients must update delegated actions or cancellation failures will not propagate.

Overview
When a smart transaction is cancelled, the linked regular transaction is now failed through TransactionController:failTransaction instead of updateTransaction, so transactionFailed, transactionStatusUpdated, and transactionFinished are emitted. That fixes cases where the tx looked failed but bridge history and similar subscribers stayed pending because they only react to those events.

@metamask/transaction-controller exposes public failTransaction(transactionId, error) and the TransactionController:failTransaction messenger action, delegating to the existing internal failure path. @metamask/smart-transactions-controller updates markRegularTransactionsAsFailed and messenger allowances to call it with a SmartTransactionFailed error (same matching rules for tx id / hashes; still skips already-failed txs).

Integrators: grant TransactionController:failTransaction to the smart transactions controller messenger (replacing TransactionController:updateTransaction for this flow).

Reviewed by Cursor Bugbot for commit 1cdd855. Bugbot is set up for automated code reviews on this repo. Configure here.

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.
@cloudonshore cloudonshore requested review from a team as code owners July 7, 2026 01:31
@matthewwalsh0 matthewwalsh0 self-requested a review July 7, 2026 11:00
* @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 {

@matthewwalsh0 matthewwalsh0 Jul 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will ultimately generate the same state as the previous update, except the explicit calls to #onTransactionStatusChange means the status change events are a conscious decision rather than derived from the state which should be the source of truth.

Could we avoid any changes to the SmartTransactionController and capture all status changes entirely automatically, if we instead scrap the calls to #onTransactionStatusChange and instead diff the previous and new status inside #updateTransactionInternal and throw the event(s) there after the update call?

Or Confirmations team could do that in a follow up to minimise effort here if time-sensitive.

@matthewwalsh0 matthewwalsh0 Jul 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the cleanest approach but it has more overhead given the additional events too, will come back to this internally later given the risk.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, agreed that's the cleaner long-term shape. Deriving the events from the status diff in #updateTransactionInternal would kill off this whole class of "caller forgot to emit" bugs and let us drop the manual #onTransactionStatusChange calls plus this failTransaction wrapper.

I kept it narrow here mainly because it's not just transactionStatusUpdated. Each transition also fires transactionFailed/transactionConfirmed/transactionDropped/transactionFinished, so doing it right means deriving all of those from the diff and pulling out the ~8 manual call sites so we don't double-fire. Felt like too much surface area to fold into a stuck-bridge fix. This version just goes through the existing #failTransaction, so the end state is identical (like you said) and it matches the other failure paths.

I was hoping we could get in this targeted fix now. I opened an issue for the follow-up refactor so Confirmations can pick it up when there's bandwidth: #9412


### Fixed

- 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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this a breaking change ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cloudonshore cloudonshore added this pull request to the merge queue Jul 7, 2026
Merged via the queue into main with commit c881134 Jul 7, 2026
417 checks passed
@cloudonshore cloudonshore deleted the fix/stx-cancelled-tx-missing-status-event branch July 7, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants