fix: fail cancelled smart transactions through the standard failure path#9400
Conversation
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.
| * @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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
Isn't this a breaking change ?
Explanation
When the transaction relay cancels a smart transaction (e.g.
FAILED_GAS_TOO_LOW— the smart tx never lands on chain),SmartTransactionsControllermarks the associated regular transaction as failed via theTransactionController:updateTransactionaction.updateTransactiononly patches state — it does not emit any transaction lifecycle events. As a result, consumers that react totransactionFailed/transactionStatusUpdatedare never notified:BridgeStatusControllermarks a bridge item failed only from thetransactionStatusUpdated/transactionFailedevent. Since that event never fires for a cancelled smart tx, the bridge history item staysPENDINGforever — the transaction shows as failed, but the bridge UI remains stuck on "Pending".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, bridgestatus.status: PENDING), while the relay'sbatchStatusreportedminedTx: cancelled/cancellationReason: too_cheap.Fix
Route the cancellation failure through the standard fail path so the lifecycle events fire.
transaction-controller: add a publicfailTransaction(transactionId, error)method and correspondingTransactionController:failTransactionmessenger action. It fails the transaction through the internal#failTransaction, emittingtransactionFailed,transactionStatusUpdated, andtransactionFinished.smart-transactions-controller:markRegularTransactionsAsFailednow callsfailTransactioninstead ofupdateTransaction.References
Changelog
@metamask/transaction-controllerfailTransactionmethod andTransactionController:failTransactionmessenger action.@metamask/smart-transactions-controllerTransactionController:failTransaction(wasupdateTransaction) so transaction lifecycle events are emitted and downstream consumers no longer leave cancelled smart transactions stuck as pending.TransactionController:failTransaction(previouslyTransactionController:updateTransaction).Checklist
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:failTransactioninstead ofupdateTransaction, sotransactionFailed,transactionStatusUpdated, andtransactionFinishedare 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-controllerexposes publicfailTransaction(transactionId, error)and theTransactionController:failTransactionmessenger action, delegating to the existing internal failure path.@metamask/smart-transactions-controllerupdatesmarkRegularTransactionsAsFailedand messenger allowances to call it with aSmartTransactionFailederror (same matching rules for tx id / hashes; still skips already-failed txs).Integrators: grant
TransactionController:failTransactionto the smart transactions controller messenger (replacingTransactionController:updateTransactionfor this flow).Reviewed by Cursor Bugbot for commit 1cdd855. Bugbot is set up for automated code reviews on this repo. Configure here.