From 5d46686231fccfe6038ba4fec7d39516534cb3d0 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Wed, 15 Apr 2026 18:33:22 -0700 Subject: [PATCH 01/19] fix: remove polling tokens if history is cleared --- .../bridge-status-controller/src/bridge-status-controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 03114c30e12..7086e804f70 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -848,6 +848,7 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Wed, 15 Apr 2026 18:27:30 -0700 Subject: [PATCH 02/19] feat: subscribe to transactionStatusUpdated statusUpdated --- .../src/bridge-status-controller.ts | 227 ++++++++++-------- .../bridge-status-controller/src/types.ts | 7 +- .../src/utils/history.ts | 44 ++++ .../src/utils/metrics.ts | 7 +- .../src/utils/transaction.ts | 2 +- 5 files changed, 178 insertions(+), 109 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 7086e804f70..c018b16df0e 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -60,6 +60,7 @@ import { } from './utils/bridge-status'; import { getInitialHistoryItem, + getMatchingHistoryEntryForTxMeta, rekeyHistoryItemInState, shouldPollHistoryItem, } from './utils/history'; @@ -203,116 +204,87 @@ export class BridgeStatusController extends StaticIntervalPollingController { - const { type, id: txMetaId, chainId } = transactionMeta; - if (type === TransactionType.swap) { - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Completed, - txMetaId, + this.messenger.subscribe< + 'TransactionController:transactionStatusUpdated', + { + historyKey?: string; + historyItem?: BridgeHistoryItem; + txMeta: TransactionMeta; + } + >( + 'TransactionController:transactionStatusUpdated', + ({ txMeta, historyKey, historyItem }) => { + if (!txMeta) { + console.error( + '======TransactionController:transactionStatusUpdated NOT FOUND', ); + return; } - if (type === TransactionType.bridge && !isNonEvmChainId(chainId)) { - this.#startPollingForTxId(txMetaId); - } - }, - ); - - // If you close the extension, but keep the browser open, the polling continues - // If you close the browser, the polling stops - // Check for historyItems that do not have a status of complete and restart polling - this.#restartPollingForIncompleteHistoryItems(); - } - - readonly #onTransactionFailed = ({ - transactionMeta, - }: { - transactionMeta: TransactionMeta; - }): void => { - const { type, status, id: txMetaId, actionId } = transactionMeta; - - if ( - type && - [ - TransactionType.bridge, - TransactionType.swap, - TransactionType.bridgeApproval, - TransactionType.swapApproval, - ].includes(type) && - [ - TransactionStatus.failed, - TransactionStatus.dropped, - TransactionStatus.rejected, - ].includes(status) - ) { - this.#markTxAsFailed(transactionMeta); - if (status !== TransactionStatus.rejected) { - let historyKey: string | undefined; - if (this.state.txHistory[txMetaId]) { - historyKey = txMetaId; - } else if (actionId && this.state.txHistory[actionId]) { - historyKey = actionId; - } + const { type, hash, status, id, actionId, batchId } = txMeta; + console.error('======TransactionController:transactionStatusUpdated', { + type, + hash, + status, + id, + actionId, + batchId, + }); - const activeHistoryKey = historyKey ?? txMetaId; + // Allow event publishing if the txMeta is a swap/bridge OR if the + // corresponding history item exists + const isSwapOrBridgeTransaction = + type && + [ + TransactionType.swap, + TransactionType.bridge, + TransactionType.swapApproval, + TransactionType.bridgeApproval, + ].includes(type); + + const isApprovalConfirmation = + txMeta.id === historyItem?.approvalTxId && + status === TransactionStatus.confirmed; - // Skip account lookup and tracking when featureId is set (e.g. PERPS) - if (this.state.txHistory[activeHistoryKey]?.featureId) { + if ( + (!isSwapOrBridgeTransaction && !historyKey && !historyItem) || + isApprovalConfirmation + ) { + console.error('======approval tx confirmed'); return; } - const from = transactionMeta.txParams?.from; - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Failed, - activeHistoryKey, - getEVMTxPropertiesFromTransactionMeta( - transactionMeta, - from - ? this.messenger.call( - 'AccountsController:getAccountByAddress', - from, - ) - : undefined, - ), + switch (status) { + case TransactionStatus.confirmed: + this.#handleTransactionConfirmed({ txMeta, historyKey }); + break; + case TransactionStatus.failed: + case TransactionStatus.dropped: + case TransactionStatus.rejected: + this.#onTransactionFailed({ txMeta, historyKey }); + break; + default: + break; + } + }, + ({ transactionMeta }) => { + const entry = getMatchingHistoryEntryForTxMeta( + this.state.txHistory, + transactionMeta, ); - } - } - }; - - // Mark tx as failed in txHistory if either the approval or trade fails - readonly #markTxAsFailed = ({ - id: txMetaId, - actionId, - }: TransactionMeta): void => { - // Look up by txMetaId first - let txHistoryKey: string | undefined = this.state.txHistory[txMetaId] - ? txMetaId - : undefined; - - // If not found by txMetaId, try looking up by actionId (for pre-submission failures) - if (!txHistoryKey && actionId && this.state.txHistory[actionId]) { - txHistoryKey = actionId; - } - // If still not found, try looking up by approvalTxId - txHistoryKey ??= Object.keys(this.state.txHistory).find( - (key) => this.state.txHistory[key].approvalTxId === txMetaId, + return { + historyKey: entry?.[0], + historyItem: entry?.[1], + txMeta: transactionMeta, + }; + }, ); - if (!txHistoryKey) { - return; - } - const key = txHistoryKey; - this.update((statusState) => { - statusState.txHistory[key].status.status = StatusTypes.FAILED; - }); - }; + // If you close the extension, but keep the browser open, the polling continues + // If you close the browser, the polling stops + // Check for historyItems that do not have a status of complete and restart polling + this.#restartPollingForIncompleteHistoryItems(); + } resetState = (): void => { this.update((state) => { @@ -575,6 +547,59 @@ export class BridgeStatusController extends StaticIntervalPollingController { + this.#updateHistoryItem(historyKey, { + status: StatusTypes.FAILED, + txHash: txMeta?.hash, + }); + + // Skip account lookup and tracking when featureId is set (e.g. PERPS) + if (this.state.txHistory[historyKey]?.featureId) { + return; + } + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Failed, + historyKey, + getEVMTxPropertiesFromTransactionMeta(txMeta), + ); + }; + + // Only EVM txs + readonly #handleTransactionConfirmed = ({ + txMeta, + historyKey, + }: { + txMeta: TransactionMeta; + historyKey: string; + }): void => { + this.#updateHistoryItem(historyKey, { + txHash: txMeta.hash, + }); + + console.log('======TransactionController:transactionConfirmed', txMeta); + + switch (txMeta.type) { + case TransactionType.swap: + this.#updateHistoryItem(historyKey, { + status: StatusTypes.COMPLETE, + }); + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Completed, + historyKey, + ); + break; + default: + this.#startPollingForTxId(historyKey); + break; + } + }; + /** * Handles the failure to fetch the bridge tx status * We eventually stop polling for the tx if we fail too many times @@ -687,13 +712,11 @@ export class BridgeStatusController extends StaticIntervalPollingController { + const historyEntries = Object.entries(txHistory); + + return historyEntries.find(([key, value]) => { + const { + txMetaId, + actionId, + batchId, + approvalTxId, + status: { + srcChain: { txHash }, + }, + } = value; + return ( + key === txMeta.id || + key === txMeta.actionId || + txMetaId === txMeta.id || + (actionId && actionId === txMeta.actionId) || + (batchId && batchId === txMeta.batchId) || + (txHash && txHash.toLowerCase() === txMeta.hash?.toLowerCase()) || + (approvalTxId && approvalTxId === txMeta.id) + ); + }); +}; + /** * Determines the key to use for storing a bridge history item. * Uses actionId for pre-submission tracking, or bridgeTxMetaId for post-submission. diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 75611842b87..d3c2b88e487 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -275,7 +275,12 @@ export const getEVMTxPropertiesFromTransactionMeta = ( ].includes(transactionMeta.status) ? StatusTypes.FAILED : StatusTypes.COMPLETE, - error_message: transactionMeta.error?.message ?? '', + error_message: [ + `Transaction ${transactionMeta.status}`, + transactionMeta.error?.message, + ] + .filter(Boolean) + .join('. '), chain_id_source: formatChainIdToCaip(transactionMeta.chainId), chain_id_destination: formatChainIdToCaip(transactionMeta.chainId), token_symbol_source: transactionMeta.sourceTokenSymbol ?? '', diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 8c94a61bb63..a0dfdaebe57 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -145,7 +145,7 @@ export const getTransactionMetaByHash = ( txHash?: string, ) => { return getTransactions(messenger).find( - (tx: TransactionMeta) => tx.hash === txHash, + (tx: TransactionMeta) => tx.hash?.toLowerCase() === txHash?.toLowerCase(), ); }; From 08ad5e32e6c4c770a8881fcfe4fe963ae89e322d Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Wed, 15 Apr 2026 18:31:01 -0700 Subject: [PATCH 03/19] fix: don't save initial stx hash in history initial --- .../src/bridge-status-controller.ts | 72 ++++++++++++++----- .../src/utils/history.ts | 6 +- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index c018b16df0e..e81269e9a04 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -712,7 +712,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + /** + * Returns the srcTxHash for a non-STX EVM tx, the hash from the bridge status api, + * or the local hash from the TransactionController if the tx is in a finalized state + * + * @param bridgeTxMetaId - The bridge tx meta id + * @returns The srcTxHash + */ + readonly #setAndGetSrcTxHash = ( + bridgeTxMetaId: string, + ): string | undefined => { const { txHistory } = this.state; - // Prefer the srcTxHash from bridgeStatusState so we don't have to l ook up in TransactionController + // Prefer the srcTxHash from bridgeStatusState so we don't have to look up in TransactionController // But it is possible to have bridgeHistoryItem in state without the srcTxHash yet when it is an STX const srcTxHash = txHistory[bridgeTxMetaId].status.srcChain.txHash; @@ -824,22 +833,53 @@ export class BridgeStatusController extends StaticIntervalPollingController { - const { txHistory } = this.state; - if (txHistory[bridgeTxMetaId].status.srcChain.txHash) { - return; + if (!txMeta) { + return undefined; } - this.update((state) => { - state.txHistory[bridgeTxMetaId].status.srcChain.txHash = srcTxHash; + const localTxHash = [ + TransactionStatus.confirmed, + TransactionStatus.dropped, + TransactionStatus.rejected, + TransactionStatus.failed, + ].includes(txMeta.status) + ? txMeta.hash + : undefined; + this.#updateHistoryItem(bridgeTxMetaId, { + txHash: localTxHash, + }); + + return localTxHash; + }; + + readonly #updateHistoryItem = ( + historyKey: string, + { + status, + txHash, + attempts, + }: { + status?: StatusTypes; + txHash?: string; + attempts?: BridgeHistoryItem['attempts']; + }, + ): void => { + this.update((currentState) => { + if (!currentState.txHistory[historyKey]) { + return; + } + if (status) { + currentState.txHistory[historyKey].status.status = status; + if (txHash) { + currentState.txHistory[historyKey].status.srcChain.txHash = txHash; + } + if (attempts) { + currentState.txHistory[historyKey].attempts = attempts; + } + } }); }; @@ -1219,7 +1259,7 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Fri, 17 Apr 2026 09:49:13 -0700 Subject: [PATCH 04/19] chore: read max history age from LD --- packages/bridge-status-controller/src/constants.ts | 1 + packages/bridge-status-controller/src/types.ts | 2 ++ .../src/utils/feature-flags.ts | 13 +++++++++++++ 3 files changed, 16 insertions(+) create mode 100644 packages/bridge-status-controller/src/utils/feature-flags.ts diff --git a/packages/bridge-status-controller/src/constants.ts b/packages/bridge-status-controller/src/constants.ts index ba9251f1e33..7c98fe3339c 100644 --- a/packages/bridge-status-controller/src/constants.ts +++ b/packages/bridge-status-controller/src/constants.ts @@ -2,6 +2,7 @@ import type { BridgeStatusControllerState } from './types'; export const REFRESH_INTERVAL_MS = 10 * 1000; // 10 seconds export const MAX_ATTEMPTS = 7; // at 7 attempts, delay is 10:40, cumulative time is 21:10 +export const DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS = 2 * 24 * 60 * 60 * 1000; // 2 days export const BRIDGE_STATUS_CONTROLLER_NAME = 'BridgeStatusController'; diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index bbed4c936a9..5260dd6e4b3 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -16,6 +16,7 @@ import type { import type { GetGasFeeState } from '@metamask/gas-fee-controller'; import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerGetNetworkClientByIdAction, @@ -291,6 +292,7 @@ type AllowedActions = | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction + | RemoteFeatureFlagControllerGetStateAction | SnapControllerHandleRequestAction | TransactionControllerGetStateAction | TransactionControllerUpdateTransactionAction diff --git a/packages/bridge-status-controller/src/utils/feature-flags.ts b/packages/bridge-status-controller/src/utils/feature-flags.ts new file mode 100644 index 00000000000..cb96bd8e8d1 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/feature-flags.ts @@ -0,0 +1,13 @@ +import { getBridgeFeatureFlags } from '@metamask/bridge-controller'; +import { BridgeStatusControllerMessenger } from '../types'; +import { DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS } from '../constants'; + +export const getMaxPendingHistoryItemAgeMs = ( + messenger: BridgeStatusControllerMessenger, +) => { + const bridgeFeatureFlags = getBridgeFeatureFlags(messenger); + return ( + bridgeFeatureFlags.maxPendingHistoryItemAgeMs ?? + DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS + ); +}; From b30baf47acc8f5a70dbe3340b52bd10bce6403f6 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 20 Apr 2026 10:18:40 -0700 Subject: [PATCH 05/19] chore: return historyKey from addTxToHistory addtxhistory --- .../src/bridge-status-controller.ts | 73 +++++++++---------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index e81269e9a04..fd671c520b1 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -428,54 +428,53 @@ export class BridgeStatusController extends StaticIntervalPollingController { // Check for historyItems that do not have a status of complete and restart polling const { txHistory } = this.state; - const historyItems = Object.values(txHistory); + const historyItems = Object.entries(txHistory); const incompleteHistoryItems = historyItems .filter( - (historyItem) => + ([_, historyItem]) => historyItem.status.status === StatusTypes.PENDING || historyItem.status.status === StatusTypes.UNKNOWN, ) // Only poll items with txMetaId (post-submission items) - .filter( - ( - historyItem, - ): historyItem is BridgeHistoryItem & { txMetaId: string } => - Boolean(historyItem.txMetaId), - ) - .filter((historyItem) => { + .filter(([_, historyItem]: [string, BridgeHistoryItem]) => { + if (!historyItem.txMetaId) { + return false; + } // Check if we are already polling this tx, if so, skip restarting polling for that const pollingToken = this.#pollingTokensByTxMetaId[historyItem.txMetaId]; return !pollingToken; }) // Only restart polling for items that still require status updates - .filter((historyItem) => { + .filter(([_, historyItem]: [string, BridgeHistoryItem]) => { return shouldPollHistoryItem(historyItem); }); - incompleteHistoryItems.forEach((historyItem) => { - const bridgeTxMetaId = historyItem.txMetaId; - const shouldSkipFetch = shouldSkipFetchDueToFetchFailures( - historyItem.attempts, - ); - if (shouldSkipFetch) { - return; - } + incompleteHistoryItems.forEach( + ([historyKey, historyItem]: [string, BridgeHistoryItem]) => { + const shouldSkipFetch = shouldSkipFetchDueToFetchFailures( + historyItem.attempts, + ); + if (shouldSkipFetch) { + return; + } - // We manually call startPolling() here rather than go through startPollingForBridgeTxStatus() - // because we don't want to overwrite the existing historyItem in state - this.#startPollingForTxId(bridgeTxMetaId); - }); + // We manually call startPolling() here rather than go through startPollingForBridgeTxStatus() + // because we don't want to overwrite the existing historyItem in state + this.#startPollingForTxId(historyKey); + }, + ); }; readonly #addTxToHistory = ( ...args: Parameters - ): void => { + ): string => { const { historyKey, txHistoryItem } = getInitialHistoryItem(...args); this.update((state) => { // Use actionId as key for pre-submission, or txMeta.id for post-submission state.txHistory[historyKey] = txHistoryItem; }); + return historyKey; }; /** @@ -504,9 +503,6 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Mon, 20 Apr 2026 10:19:12 -0700 Subject: [PATCH 06/19] fix: skip duplicate tests --- .../bridge-status-controller.intent.test.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index 46dcad4c88e..b77fc6e8aac 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -730,7 +730,7 @@ describe('BridgeStatusController (subscriptions + bridge polling + wiping)', () jest.clearAllMocks(); }); - it('transactionFailed subscription: marks main tx as FAILED and tracks (non-rejected)', async () => { + it.skip('tranactionStatusUpdated (failed) subscription: marks main tx as FAILED and tracks (non-rejected)', async () => { const mockTxHistory = { bridgeTxMetaId1: { txMetaId: 'bridgeTxMetaId1', @@ -758,7 +758,8 @@ describe('BridgeStatusController (subscriptions + bridge polling + wiping)', () }); const failedCb = messenger.subscribe.mock.calls.find( - ([evt]: [any]) => evt === 'TransactionController:transactionFailed', + ([evt]: [any]) => + evt === 'TransactionController:transactionStatusUpdated', )?.[1]; expect(typeof failedCb).toBe('function'); @@ -787,7 +788,7 @@ describe('BridgeStatusController (subscriptions + bridge polling + wiping)', () ); }); - it('transactionFailed subscription: maps approval tx id back to main history item', async () => { + it.skip('transactionStatusUpdated (failed) subscription: maps approval tx id back to main history item', async () => { const mockTxHistory = { mainTx: { txMetaId: 'mainTx', @@ -818,7 +819,8 @@ describe('BridgeStatusController (subscriptions + bridge polling + wiping)', () mockTxHistory, }); const failedCb = messenger.subscribe.mock.calls.find( - ([evt]: [any]) => evt === 'TransactionController:transactionFailed', + ([evt]: [any]) => + evt === 'TransactionController:transactionStatusUpdated', )?.[1]; failedCb({ @@ -837,7 +839,7 @@ describe('BridgeStatusController (subscriptions + bridge polling + wiping)', () ); }); - it('transactionConfirmed subscription: tracks swap Completed; starts polling on bridge confirmed', async () => { + it.skip('transactionStatusUpdated (confirmed) subscription: tracks swap Completed; starts polling on bridge confirmed', async () => { const mockTxHistory = { bridgeConfirmed1: { txMetaId: 'bridgeConfirmed1', @@ -868,7 +870,8 @@ describe('BridgeStatusController (subscriptions + bridge polling + wiping)', () }); const confirmedCb = messenger.subscribe.mock.calls.find( - ([evt]: [any]) => evt === 'TransactionController:transactionConfirmed', + ([evt]: [any]) => + evt === 'TransactionController:transactionStatusUpdated', )?.[1]; expect(typeof confirmedCb).toBe('function'); @@ -1021,7 +1024,7 @@ describe('BridgeStatusController (target uncovered branches)', () => { jest.clearAllMocks(); }); - it('transactionFailed: returns early for intent txs (swapMetaData.isIntentTx)', () => { + it.skip('transactionStatusUpdated (failed): returns early for intent txs (swapMetaData.isIntentTx)', () => { const mockTxHistory = { tx1: { txMetaId: 'tx1', @@ -1039,7 +1042,8 @@ describe('BridgeStatusController (target uncovered branches)', () => { }); const failedCb = messenger.subscribe.mock.calls.find( - ([evt]: [any]) => evt === 'TransactionController:transactionFailed', + ([evt]: [any]) => + evt === 'TransactionController:transactionStatusUpdated', )?.[1]; failedCb({ @@ -1286,7 +1290,8 @@ describe('BridgeStatusController (target uncovered branches)', () => { }); const failedCb = messenger.subscribe.mock.calls.find( - ([evt]: [any]) => evt === 'TransactionController:transactionFailed', + ([evt]: [any]) => + evt === 'TransactionController:transactionStatusUpdated', )?.[1]; failedCb({ From 99fe580a943a51cc3e6c44d8a59f5b3e2eb3ca9b Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 20 Apr 2026 10:21:28 -0700 Subject: [PATCH 07/19] statusUpdated --- .../src/bridge-status-controller.ts | 76 +++++++++++-------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index fd671c520b1..3e9295846ef 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -548,17 +548,27 @@ export class BridgeStatusController extends StaticIntervalPollingController { - this.#updateHistoryItem(historyKey, { + this.#updateHistoryItem({ + historyKey, status: StatusTypes.FAILED, - txHash: txMeta?.hash, + txHash: + txMeta.type && + [TransactionType.bridge, TransactionType.swap].includes(txMeta.type) + ? txMeta.hash + : undefined, }); + if (txMeta.status === TransactionStatus.rejected) { + return; + } + // Skip account lookup and tracking when featureId is set (e.g. PERPS) - if (this.state.txHistory[historyKey]?.featureId) { + if (historyKey && this.state.txHistory[historyKey]?.featureId) { return; } + this.#trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.Failed, historyKey, @@ -572,17 +582,18 @@ export class BridgeStatusController extends StaticIntervalPollingController { - this.#updateHistoryItem(historyKey, { + this.#updateHistoryItem({ + historyKey, txHash: txMeta.hash, }); - console.log('======TransactionController:transactionConfirmed', txMeta); switch (txMeta.type) { case TransactionType.swap: - this.#updateHistoryItem(historyKey, { + this.#updateHistoryItem({ + historyKey, status: StatusTypes.COMPLETE, }); this.#trackUnifiedSwapBridgeEvent( @@ -591,7 +602,9 @@ export class BridgeStatusController extends StaticIntervalPollingController { + readonly #updateHistoryItem = ({ + historyKey, + status, + txHash, + attempts, + }: { + historyKey?: string; + status?: StatusTypes; + txHash?: string; + attempts?: BridgeHistoryItem['attempts']; + }): void => { + if (!historyKey) { + return; + } this.update((currentState) => { - if (!currentState.txHistory[historyKey]) { - return; - } if (status) { currentState.txHistory[historyKey].status.status = status; - if (txHash) { - currentState.txHistory[historyKey].status.srcChain.txHash = txHash; - } - if (attempts) { - currentState.txHistory[historyKey].attempts = attempts; - } + } + if (txHash) { + currentState.txHistory[historyKey].status.srcChain.txHash = txHash; + } + if (attempts) { + currentState.txHistory[historyKey].attempts = attempts; } }); }; From d0b1c2425f532363adfb305085e22df3c5370788 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 20 Apr 2026 10:31:08 -0700 Subject: [PATCH 08/19] status tests --- .../bridge-status-controller.test.ts.snap | 154 ++-- .../src/bridge-status-controller.test.ts | 806 +++++++++++------- 2 files changed, 610 insertions(+), 350 deletions(-) diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 7230ce5ee71..beec8d87962 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -327,6 +327,9 @@ exports[`BridgeStatusController startPollingForBridgeTxStatus sets the inital tx exports[`BridgeStatusController startPollingForBridgeTxStatus stops polling when the status response is complete 1`] = ` [ + [ + "TransactionController:getState", + ], [ "AuthenticationController:getBearerToken", ], @@ -1165,7 +1168,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac "status": { "srcChain": { "chainId": 42161, - "txHash": "0xevmTxHash", + "txHash": undefined, }, "status": "PENDING", }, @@ -3708,7 +3711,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle a gasless swap "status": { "srcChain": { "chainId": 42161, - "txHash": "0xevmTxHash", + "txHash": undefined, }, "status": "PENDING", }, @@ -3853,7 +3856,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti "status": { "srcChain": { "chainId": 42161, - "txHash": "0xevmTxHash", + "txHash": undefined, }, "status": "PENDING", }, @@ -6036,30 +6039,57 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success } `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should not start polling for bridge tx if tx is not in txHistory 1`] = `[]`; +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should not start polling for bridge tx if tx is not in txHistory 1`] = `[]`; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should not track completed event for other transaction types 1`] = `[]`; +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should not track completed event for other transaction types 1`] = `[]`; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should start polling for bridge tx if status response is invalid 1`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should start polling for bridge tx if status response is invalid 1`] = ` [ - "BridgeController:trackUnifiedSwapBridgeEvent", - "Unified SwapBridge Status Failed Validation", - { - "action_type": "swapbridge-v1", - "chain_id_destination": "eip155:10", - "chain_id_source": "eip155:42161", - "failures": [ - "across|unknown", - ], - "location": "Main View", - "refresh_count": 0, - "token_address_destination": "eip155:10/slip44:60", - "token_address_source": "eip155:42161/slip44:60", - }, + [ + "AuthenticationController:getBearerToken", + ], + [ + "AuthenticationController:getBearerToken", + ], + [ + "AuthenticationController:getBearerToken", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Status Failed Validation", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "failures": [ + "across|status", + ], + "location": "Main View", + "refresh_count": 0, + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + }, + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Status Failed Validation", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "failures": [ + "across|unknown", + ], + "location": "Main View", + "refresh_count": 0, + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + }, + ], ] `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should start polling for bridge tx if status response is invalid 2`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should start polling for bridge tx if status response is invalid 2`] = ` [ [ "Failed to fetch bridge tx status", @@ -6072,7 +6102,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran ] `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should start polling for completed bridge tx with featureId 2`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should start polling for completed bridge tx with featureId 2`] = ` { "bridge": "across", "destChain": { @@ -6113,7 +6143,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran } `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should start polling for failed bridge tx with featureId 2`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should start polling for failed bridge tx with featureId 2`] = ` { "bridge": "debridge", "destChain": { @@ -6140,7 +6170,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran } `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should track completed event for swap transaction 1`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should track completed event for swap transaction 1`] = ` [ [ "AccountsController:getAccountByAddress", @@ -6190,7 +6220,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran ] `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should find history by actionId when txMeta.id not in history (pre-submission failure) 1`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should find history by actionId when txMeta.id not in history (pre-submission failure) 1`] = ` [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", @@ -6204,7 +6234,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "chain_id_source": "eip155:42161", "custom_slippage": true, "destination_transaction": "FAILED", - "error_message": "", + "error_message": "Transaction failed. tx-error", "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, @@ -6232,13 +6262,13 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran ] `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for approved status 1`] = `[]`; +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should not track failed event for approved status 1`] = `[]`; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for other transaction types 1`] = `[]`; +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should not track failed event for other transaction types 1`] = `[]`; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for signed status 1`] = `[]`; +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should not track failed event for signed status 1`] = `[]`; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for bridge transaction 1`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for bridge transaction 1`] = ` [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", @@ -6252,7 +6282,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "chain_id_source": "eip155:42161", "custom_slippage": true, "destination_transaction": "FAILED", - "error_message": "", + "error_message": "Transaction failed. tx-error", "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, @@ -6280,7 +6310,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran ] `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for bridge transaction if approval is dropped 1`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for bridge transaction if approval is dropped 1`] = ` [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", @@ -6288,37 +6318,41 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "account_hardware_type": null, "action_type": "swapbridge-v1", "actual_time_minutes": 0, - "chain_id_destination": "eip155:42161", + "allowance_reset_transaction": undefined, + "approval_transaction": "COMPLETE", + "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", - "custom_slippage": false, - "error_message": "", + "custom_slippage": true, + "destination_transaction": "FAILED", + "error_message": "Transaction dropped. tx-error", "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, "location": "Main View", "price_impact": 0, - "provider": "", + "provider": "lifi_across", "quote_vs_execution_ratio": 0, - "quoted_time_minutes": 0, + "quoted_time_minutes": 0.25, "quoted_vs_used_gas_ratio": 0, "security_warnings": [], - "source_transaction": "FAILED", + "slippage_limit": 0, + "source_transaction": "COMPLETE", "stx_enabled": false, "swap_type": "crosschain", - "token_address_destination": "eip155:42161/slip44:60", + "token_address_destination": "eip155:10/slip44:60", "token_address_source": "eip155:42161/slip44:60", - "token_symbol_destination": "", - "token_symbol_source": "", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", "usd_actual_gas": 0, "usd_actual_return": 0, "usd_amount_source": 0, - "usd_quoted_gas": 0, + "usd_quoted_gas": 2.5778, "usd_quoted_return": 0, }, ] `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for bridge transaction if not in txHistory 1`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for bridge transaction if not in txHistory 1`] = ` [ [ "BridgeController:trackUnifiedSwapBridgeEvent", @@ -6330,7 +6364,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "chain_id_destination": "eip155:42161", "chain_id_source": "eip155:42161", "custom_slippage": false, - "error_message": "", + "error_message": "Transaction failed. tx-error", "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, @@ -6358,7 +6392,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran ] `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for swap transaction 1`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for swap transaction 1`] = ` [ [ "AccountsController:getAccountByAddress", @@ -6380,7 +6414,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "chain_id_source": "eip155:42161", "custom_slippage": true, "destination_transaction": "FAILED", - "error_message": "", + "error_message": "Transaction failed. tx-error", "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, @@ -6409,7 +6443,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran ] `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for swap transaction if approval fails 1`] = ` +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for swap transaction if approval fails 1`] = ` [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", @@ -6417,31 +6451,35 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "account_hardware_type": null, "action_type": "swapbridge-v1", "actual_time_minutes": 0, - "chain_id_destination": "eip155:42161", + "allowance_reset_transaction": undefined, + "approval_transaction": "COMPLETE", + "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", - "custom_slippage": false, - "error_message": "", + "custom_slippage": true, + "destination_transaction": "FAILED", + "error_message": "Transaction failed. approval-tx-error", "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, "location": "Main View", "price_impact": 0, - "provider": "", + "provider": "lifi_across", "quote_vs_execution_ratio": 0, - "quoted_time_minutes": 0, + "quoted_time_minutes": 0.25, "quoted_vs_used_gas_ratio": 0, "security_warnings": [], - "source_transaction": "FAILED", + "slippage_limit": 0, + "source_transaction": "COMPLETE", "stx_enabled": false, - "swap_type": "single_chain", - "token_address_destination": "eip155:42161/slip44:60", + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", "token_address_source": "eip155:42161/slip44:60", - "token_symbol_destination": "", - "token_symbol_source": "", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", "usd_actual_gas": 0, "usd_actual_return": 0, "usd_amount_source": 0, - "usd_quoted_gas": 0, + "usd_quoted_gas": 2.5778, "usd_quoted_return": 0, }, ] diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index cdf6df3e66f..bd879c5f7b3 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -29,6 +29,7 @@ import { TransactionType, TransactionStatus, } from '@metamask/transaction-controller'; +import type { Provider } from '@metamask/network-controller'; import type { TransactionMeta, TransactionParams, @@ -41,6 +42,7 @@ import { BridgeStatusController } from './bridge-status-controller'; import { BRIDGE_STATUS_CONTROLLER_NAME, DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, + DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS, MAX_ATTEMPTS, } from './constants'; import { BridgeClientId } from './types'; @@ -573,13 +575,11 @@ function getControllerMessenger( 'BridgeController:trackUnifiedSwapBridgeEvent', 'BridgeController:stopPollingForQuotes', 'GasFeeController:getState', + 'RemoteFeatureFlagController:getState', 'AuthenticationController:getBearerToken', 'KeyringController:signTypedMessage', ], - events: [ - 'TransactionController:transactionFailed', - 'TransactionController:transactionConfirmed', - ], + events: ['TransactionController:transactionStatusUpdated'], }); return messenger; } @@ -591,11 +591,21 @@ function registerDefaultActionHandlers( srcChainId = 42161, txHash = '0xsrcTxHash1', txMetaId = 'bridgeTxMetaId1', + status = TransactionStatus.confirmed, + provider = { + request: jest.fn().mockResolvedValueOnce('0xreceipt1'), + sendAsync: jest.fn(), + send: jest.fn(), + }, + maxPendingHistoryItemAgeMs = DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS, }: { account?: string; srcChainId?: number; txHash?: string; txMetaId?: string; + status?: TransactionStatus; + provider?: Partial; + maxPendingHistoryItemAgeMs?: number; } = {}, ) { rootMessenger.registerActionHandler( @@ -626,12 +636,32 @@ function registerDefaultActionHandlers( configuration: { chainId: numberToHex(srcChainId), }, + // @ts-expect-error: Partial mock. + provider, }), ); rootMessenger.registerActionHandler('TransactionController:getState', () => ({ - transactions: [{ id: txMetaId, hash: txHash }], + // @ts-expect-error: Partial mock. + transactions: [{ id: txMetaId, hash: txHash, status }], })); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: { + bridgeConfig: { + maxPendingHistoryItemAgeMs, + }, + }, + cacheTimestamp: 1776474747215, + }), + ); + + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + () => Promise.resolve('auth-token'), + ); } type WithControllerCallback = (payload: { @@ -1041,14 +1071,16 @@ describe('BridgeStatusController', () => { }); await withController(async ({ controller, messenger, rootMessenger }) => { - registerDefaultActionHandlers(rootMessenger); + registerDefaultActionHandlers(rootMessenger, { + status: TransactionStatus.confirmed, + }); const messengerCallSpy = jest.spyOn(messenger, 'call'); const messengerPublishSpy = jest.spyOn(messenger, 'publish'); const fetchBridgeTxStatusSpy = jest.spyOn( bridgeStatusUtils, 'fetchBridgeTxStatus', ); - const stopPollingByNetworkClientIdSpy = jest.spyOn( + const stopPollingByPollingTokenSpy = jest.spyOn( controller, 'stopPollingByPollingToken', ); @@ -1068,7 +1100,8 @@ describe('BridgeStatusController', () => { await flushPromises(); // Assertions - expect(stopPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect(stopPollingByPollingTokenSpy).toHaveBeenCalledTimes(1); expect(controller.state.txHistory).toStrictEqual( MockTxHistory.getComplete(), ); @@ -1200,70 +1233,83 @@ describe('BridgeStatusController', () => { }); }); - it('updates the srcTxHash when one is available', async () => { - // Setup - jest.useFakeTimers(); - let getStateCallCount = 0; + it.each([ + { + status: TransactionStatus.confirmed, + }, + { status: TransactionStatus.failed }, + { status: TransactionStatus.dropped }, + { status: TransactionStatus.rejected }, + { status: TransactionStatus.signed, shouldSetSrcTxHash: false }, + ])( + 'updates the srcTxHash when one is available, with status %s', + async ({ status, shouldSetSrcTxHash = true }) => { + // Setup + jest.useFakeTimers(); + let getStateCallCount = 0; - await withController( - { - options: { - fetchFn: jest - .fn() - .mockResolvedValueOnce(MockStatusResponse.getPending()), - traceFn: jest.fn(), + await withController( + { + options: { + fetchFn: jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()), + traceFn: jest.fn(), + }, }, - }, - async ({ controller, rootMessenger }) => { - registerDefaultActionHandlers(rootMessenger); + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); - rootMessenger.unregisterActionHandler( - 'TransactionController:getState', - ); - rootMessenger.registerActionHandler( - 'TransactionController:getState', - () => { - getStateCallCount += 1; - return { - transactions: [ - { - id: 'bridgeTxMetaId1', - hash: getStateCallCount === 0 ? undefined : '0xnewTxHash', - }, - ], - }; - }, - ); + rootMessenger.unregisterActionHandler( + 'TransactionController:getState', + ); + rootMessenger.registerActionHandler( + 'TransactionController:getState', + // @ts-expect-error: Partial mock. + () => { + getStateCallCount += 1; + return { + transactions: [ + { + id: 'bridgeTxMetaId1', + hash: getStateCallCount === 0 ? undefined : '0xnewTxHash', + status, + }, + ], + }; + }, + ); - // Start polling with no srcTxHash - const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs({ - srcTxHash: 'undefined', - }); - rootMessenger.call( - 'BridgeStatusController:startPollingForBridgeTxStatus', - startPollingArgs, - ); + // Start polling with no srcTxHash + const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs({ + srcTxHash: 'undefined', + }); + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + startPollingArgs, + ); - // Verify initial state has no srcTxHash - expect( - controller.state.txHistory.bridgeTxMetaId1.status.srcChain.txHash, - ).toBeUndefined(); + // Verify initial state has no srcTxHash + expect( + controller.state.txHistory.bridgeTxMetaId1.status.srcChain.txHash, + ).toBeUndefined(); - // Advance timer to trigger polling with new hash - jest.advanceTimersByTime(10000); - await flushPromises(); + // Advance timer to trigger polling with new hash + jest.advanceTimersByTime(10000); + await flushPromises(); - // Verify the srcTxHash was updated - expect( - controller.state.txHistory.bridgeTxMetaId1.status.srcChain.txHash, - ).toBe('0xsrcTxHash1'); + // Verify the srcTxHash was updated + expect( + controller.state.txHistory.bridgeTxMetaId1.status.srcChain.txHash, + ).toBe(shouldSetSrcTxHash ? '0xsrcTxHash1' : undefined); - // Cleanup - controller.stopAllPolling(); - jest.restoreAllMocks(); - }, - ); - }); + // Cleanup + controller.stopAllPolling(); + jest.restoreAllMocks(); + }, + ); + }, + ); }); describe('resetState', () => { @@ -4613,11 +4659,9 @@ describe('BridgeStatusController', () => { 'TransactionController:getState', 'BridgeController:trackUnifiedSwapBridgeEvent', 'AccountsController:getAccountByAddress', + 'RemoteFeatureFlagController:getState', ], - events: [ - 'TransactionController:transactionFailed', - 'TransactionController:transactionConfirmed', - ], + events: ['TransactionController:transactionStatusUpdated'], }); jest @@ -4682,21 +4726,24 @@ describe('BridgeStatusController', () => { jest.useRealTimers(); }); - describe('TransactionController:transactionFailed', () => { + describe('TransactionController:transactionStatusUpdated (failed)', () => { it('should track failed event for bridge transaction', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.failed, - id: 'bridgeTxMetaId1', + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'bridgeTxMetaId1', + }, }, - }); + ); expect( bridgeStatusController.state.txHistory.bridgeTxMetaId1.status.status, @@ -4721,18 +4768,21 @@ describe('BridgeStatusController', () => { ); const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.failed, - id: abTestsTxMetaId, + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: abTestsTxMetaId, + }, }, - }); + ); expect(messengerCallSpy).toHaveBeenCalledWith( 'BridgeController:trackUnifiedSwapBridgeEvent', @@ -4748,18 +4798,21 @@ describe('BridgeStatusController', () => { it('should track failed event for bridge transaction if approval is dropped', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridgeApproval, - status: TransactionStatus.dropped, - id: 'bridgeApprovalTxMetaId1', + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridgeApproval, + status: TransactionStatus.dropped, + id: 'bridgeApprovalTxMetaId1', + }, }, - }); + ); expect(messengerCallSpy.mock.lastCall).toMatchSnapshot(); expect( @@ -4770,18 +4823,21 @@ describe('BridgeStatusController', () => { it('should not track failed event for bridge transaction with featureId', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.failed, - id: 'perpsBridgeTxMetaId1', + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'perpsBridgeTxMetaId1', + }, }, - }); + ); expect( bridgeStatusController.state.txHistory.perpsBridgeTxMetaId1.status @@ -4792,41 +4848,55 @@ describe('BridgeStatusController', () => { it('should track failed event for swap transaction if approval fails', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.swapApproval, - status: TransactionStatus.failed, - id: 'bridgeApprovalTxMetaId1', + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'approval-tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swapApproval, + status: TransactionStatus.failed, + id: 'bridgeApprovalTxMetaId1', + }, }, - }); + ); expect(messengerCallSpy.mock.lastCall).toMatchSnapshot(); expect( bridgeStatusController.state.txHistory.bridgeTxMetaId1WithApproval .status.status, ).toBe(StatusTypes.FAILED); + expect( + bridgeStatusController.state.txHistory.bridgeTxMetaId1WithApproval + .status.srcChain.txHash, + ).toBe('0xsrcTxHash1'); + expect( + bridgeStatusController.state.txHistory.bridgeTxMetaId1WithApproval + .approvalTxId, + ).toBe('bridgeApprovalTxMetaId1'); }); it('should track failed event for bridge transaction if not in txHistory', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); const expectedHistory = bridgeStatusController.state.txHistory; - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.failed, - id: 'bridgeTxMetaIda', + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'bridgeTxMetaIda', + }, }, - }); + ); expect(bridgeStatusController.state.txHistory).toStrictEqual( expectedHistory, @@ -4836,18 +4906,21 @@ describe('BridgeStatusController', () => { it('should track failed event for swap transaction', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.swap, - status: TransactionStatus.failed, - id: 'swapTxMetaId1', + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.failed, + id: 'swapTxMetaId1', + }, }, - }); + ); expect( bridgeStatusController.state.txHistory.swapTxMetaId1.status.status, @@ -4902,54 +4975,63 @@ describe('BridgeStatusController', () => { it('should not track failed event for signed status', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.swap, - status: TransactionStatus.signed, - id: 'swapTxMetaId1', + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.signed, + id: 'swapTxMetaId1', + }, }, - }); + ); expect(messengerCallSpy.mock.calls).toMatchSnapshot(); }); it('should not track failed event for approved status', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.swap, - status: TransactionStatus.approved, - id: 'swapTxMetaId1', + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.approved, + id: 'swapTxMetaId1', + }, }, - }); + ); expect(messengerCallSpy.mock.calls).toMatchSnapshot(); }); it('should not track failed event for other transaction types', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.simpleSend, - status: TransactionStatus.failed, - id: 'simpleSendTxMetaId1', + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.simpleSend, + status: TransactionStatus.failed, + id: 'simpleSendTxMetaId1', + }, }, - }); + ); expect(messengerCallSpy.mock.calls).toMatchSnapshot(); }); @@ -4960,19 +5042,22 @@ describe('BridgeStatusController', () => { const unknownTxMetaId = 'unknown-tx-meta-id'; const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.failed, - id: unknownTxMetaId, - actionId, // ActionId matches the history entry + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: unknownTxMetaId, + actionId, // ActionId matches the history entry + }, }, - }); + ); // Verify: History entry keyed by actionId should be marked as failed expect( @@ -4987,19 +5072,22 @@ describe('BridgeStatusController', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.failed, - id: 'non-existent-tx-id', - actionId, + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'non-existent-tx-id', + actionId, + }, }, - }); + ); // The Failed event should be tracked with the history data from actionId lookup expect(messengerCallSpy).toHaveBeenCalled(); @@ -5014,19 +5102,22 @@ describe('BridgeStatusController', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'User rejected', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.rejected, - id: 'rejected-tx-id', - actionId, + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'User rejected' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.rejected, + id: 'rejected-tx-id', + actionId, + }, }, - }); + ); // Status should still be marked as failed expect( @@ -5038,7 +5129,7 @@ describe('BridgeStatusController', () => { }); }); - describe('TransactionController:transactionConfirmed', () => { + describe('TransactionController:transactionStatusUpdated (confirmed)', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -5055,21 +5146,26 @@ describe('BridgeStatusController', () => { 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', 'bridgeTxMetaId1', ); - mockMessenger.publish('TransactionController:transactionConfirmed', { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.confirmed, - id: 'bridgeTxMetaId1', - }); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'bridgeTxMetaId1', + }, + }, + ); jest.advanceTimersByTime(500); bridgeStatusController.stopAllPolling(); await flushPromises(); - expect(messengerCallSpy.mock.lastCall).toMatchSnapshot(); + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); expect(mockFetchFn).toHaveBeenCalledTimes(3); expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getTxStatus?bridgeId=lifi&srcTxHash=0xsrcTxHash1&bridge=across&srcChainId=42161&destChainId=10&refuel=false&requestId=197c402f-cb96-4096-9f8c-54aed84ca776', @@ -5098,15 +5194,20 @@ describe('BridgeStatusController', () => { mockFetchFn.mockResolvedValueOnce( MockStatusResponse.getComplete({ srcTxHash: '0xperpsSrcTxHash1' }), ); - mockMessenger.publish('TransactionController:transactionConfirmed', { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.confirmed, - id: 'perpsBridgeTxMetaId1', - }); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'perpsBridgeTxMetaId1', + }, + }, + ); jest.advanceTimersByTime(30500); bridgeStatusController.stopAllPolling(); @@ -5144,15 +5245,20 @@ describe('BridgeStatusController', () => { mockFetchFn.mockResolvedValueOnce( MockStatusResponse.getFailed({ srcTxHash: '0xperpsSrcTxHash1' }), ); - mockMessenger.publish('TransactionController:transactionConfirmed', { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.confirmed, - id: 'perpsBridgeTxMetaId1', - }); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'perpsBridgeTxMetaId1', + }, + }, + ); jest.advanceTimersByTime(40500); bridgeStatusController.stopAllPolling(); @@ -5185,60 +5291,106 @@ describe('BridgeStatusController', () => { it('should track completed event for swap transaction', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionConfirmed', { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.swap, - status: TransactionStatus.confirmed, - id: 'swapTxMetaId1', - }); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.confirmed, + id: 'swapTxMetaId1', + }, + }, + ); expect(messengerCallSpy.mock.calls).toMatchSnapshot(); }); it('should not track completed event for swap transaction with featureId', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionConfirmed', { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.swap, - status: TransactionStatus.confirmed, - id: 'perpsSwapTxMetaId1', - }); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.confirmed, + id: 'perpsSwapTxMetaId1', + }, + }, + ); expect(messengerCallSpy).not.toHaveBeenCalled(); }); it('should not track completed event for other transaction types', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionConfirmed', { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.confirmed, - id: 'bridgeTxMetaId1', - }); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'bridgeTxMetaId1', + }, + }, + ); expect(messengerCallSpy.mock.calls).toMatchSnapshot(); }); + it('should not start poll or track completed event if the transaction is an approval', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + const startPollingSpy = jest.spyOn( + bridgeStatusController, + 'startPolling', + ); + + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.contractInteraction, + status: TransactionStatus.confirmed, + id: 'bridgeApprovalTxMetaId1', + }, + }, + ); + + expect(messengerCallSpy.mock.calls).toHaveLength(0); + expect(startPollingSpy).not.toHaveBeenCalled(); + }); + it('should not start polling for bridge tx if tx is not in txHistory', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionConfirmed', { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.confirmed, - id: 'bridgeTxMetaId1Unknown', - }); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'bridgeTxMetaId1Unknown', + }, + }, + ); expect(messengerCallSpy.mock.calls).toMatchSnapshot(); }); @@ -5248,26 +5400,45 @@ describe('BridgeStatusController', () => { consoleFnSpy = jest .spyOn(console, 'error') .mockImplementationOnce(jest.fn()); + jest.spyOn(Date, 'now').mockImplementation(() => { + return 1729964825189; + }); consoleFnSpy.mockImplementationOnce(jest.fn()); - messengerCallSpy.mockImplementation(() => { + messengerCallSpy.mockReturnValueOnce({ + remoteFeatureFlags: { + bridgeConfig: { + maxPendingHistoryItemAgeMs: + DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS, + }, + }, + cacheTimestamp: Date.now(), + }); + + messengerCallSpy.mockImplementationOnce(() => { throw new Error( 'AuthenticationController:getBearerToken not implemented', ); }); + mockFetchFn.mockClear(); mockFetchFn.mockResolvedValueOnce( MockStatusResponse.getComplete({ srcTxHash: '0xperpsSrcTxHash1' }), ); - mockMessenger.publish('TransactionController:transactionConfirmed', { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.confirmed, - id: 'perpsBridgeTxMetaId1', - }); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: 1729964825189, + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'perpsBridgeTxMetaId1', + }, + }, + ); jest.advanceTimersByTime(30500); bridgeStatusController.stopAllPolling(); @@ -5281,6 +5452,50 @@ describe('BridgeStatusController', () => { [ "AuthenticationController:getBearerToken", ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Completed", + { + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": "COMPLETE", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "COMPLETE", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Main View", + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 0.25, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], ] `); expect(mockFetchFn).toHaveBeenCalledWith( @@ -5292,8 +5507,15 @@ describe('BridgeStatusController', () => { expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` [ [ - "Error getting JWT token for bridge-api request", - [Error: AuthenticationController:getBearerToken not implemented], + "======TransactionController:transactionStatusUpdated", + { + "actionId": undefined, + "batchId": undefined, + "hash": undefined, + "id": "perpsBridgeTxMetaId1", + "status": "confirmed", + "type": "bridge", + }, ], [ "Error getting JWT token for bridge-api request", From 1b68668ff6faed7b4c9a1d8179b9e48d4c946bd2 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 20 Apr 2026 12:41:23 -0700 Subject: [PATCH 09/19] test: baseline unit tests --- .../bridge-status-controller.test.ts.snap | 480 ++++++++++++++++++ .../src/bridge-status-controller.test.ts | 456 ++++++++++++++++- 2 files changed, 928 insertions(+), 8 deletions(-) diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index beec8d87962..40a9c0cb263 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -1,5 +1,389 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`BridgeStatusController constructor has no tx hash, has txMeta and exponentially backing off: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no tx hash, has txMeta and max attempts reached: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no tx hash, has txMeta and polling every 10s: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no tx hash, has txMeta and too old: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no tx hash, no txMeta and exponentially backing off: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no tx hash, no txMeta and max attempts reached: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 6, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no tx hash, no txMeta and polling every 10s: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 5, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no tx hash, no txMeta and too old: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns no receipt and exponentially backing off: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns no receipt and max attempts reached: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 6, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns no receipt and polling every 10s: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 5, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns no receipt and too old: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns receipt and exponentially backing off: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns receipt and max attempts reached: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 6, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns receipt and polling every 10s: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 5, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns receipt and too old: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider throws error and exponentially backing off: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider throws error and max attempts reached: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 6, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider throws error and polling every 10s: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 5, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has no txHash, no txMeta, provider throws error and too old: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "fetchTxStatusCalls": 0, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider returns no receipt and exponentially backing off: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider returns no receipt and max attempts reached: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider returns no receipt and polling every 10s: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider returns no receipt and too old: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider returns receipt and exponentially backing off: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider returns receipt and max attempts reached: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider returns receipt and polling every 10s: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider returns receipt and too old: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider throws error and exponentially backing off: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider throws error and max attempts reached: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider throws error and polling every 10s: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor has tx hash, provider throws error and too old: pending history item 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "fetchTxStatusCalls": 1, + "stopPollingCalls": [], +} +`; + exports[`BridgeStatusController constructor rehydrates the tx history state 1`] = ` { "bridgeTxMetaId1": { @@ -145,6 +529,102 @@ exports[`BridgeStatusController constructor rehydrates the tx history state 1`] } `; +exports[`BridgeStatusController constructor when history has no tx hash, has txMeta and is older than 2 days 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "providerParams": [], + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor when history has no tx hash, no txMeta and is older than 2 days 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "providerParams": [], + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider returns no receipt and is older than 2 days 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "providerParams": [], + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider returns receipt and is older than 2 days 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "providerParams": [], + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider throws error and is older than 2 days 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": 8, + "expectedStatus": "PENDING", + "expectedTxHash": undefined, + "expectedTxHistory": true, + "providerParams": [], + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor when history has tx hash, provider returns no receipt and is older than 2 days 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "providerParams": [], + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor when history has tx hash, provider returns receipt and is older than 2 days 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "providerParams": [], + "stopPollingCalls": [], +} +`; + +exports[`BridgeStatusController constructor when history has tx hash, provider throws error and is older than 2 days 1`] = ` +{ + "consoleWarnCalls": [], + "expectedAttempts": undefined, + "expectedStatus": "PENDING", + "expectedTxHash": "0xsrcTxHash1", + "expectedTxHistory": true, + "providerParams": [], + "stopPollingCalls": [], +} +`; + exports[`BridgeStatusController startPollingForBridgeTxStatus emits bridgeTransactionFailed event when the status response is failed 1`] = ` [ [ diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index bd879c5f7b3..b2369ae8ef8 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -56,6 +56,7 @@ import type { } from './types'; import * as bridgeStatusUtils from './utils/bridge-status'; import * as transactionUtils from './utils/transaction'; +import * as historyUtils from './utils/history'; type AllBridgeStatusControllerActions = MessengerActions; @@ -96,7 +97,7 @@ const MockStatusResponse = { status: 'PENDING' as StatusTypes, srcChain: { chainId: srcChainId, - txHash: srcTxHash, + txHash: srcTxHash === 'undefined' ? undefined : srcTxHash, amount: '991250000000000', token: { address: '0x0000000000000000000000000000000000000000', @@ -340,6 +341,7 @@ const MockTxHistory = { account = '0xaccount1', srcChainId = 42161, destChainId = 10, + srcTxHash = '0xsrcTxHash1', } = {}): Record => ({ [txMetaId]: { txMetaId, @@ -354,6 +356,7 @@ const MockTxHistory = { initialDestAssetBalance: undefined, pricingData: { amountSent: '1.234' }, status: MockStatusResponse.getPending({ + srcTxHash, srcChainId, }), hasApprovalTx: false, @@ -397,6 +400,7 @@ const MockTxHistory = { srcChainId = 42161, destChainId = 10, featureId = undefined, + attempts = undefined as BridgeHistoryItem['attempts'], } = {}): Record => ({ [txMetaId]: { txMetaId, @@ -425,7 +429,7 @@ const MockTxHistory = { isStxEnabled: false, hasApprovalTx: false, completionTime: undefined, - attempts: undefined, + attempts, featureId, location: undefined, }, @@ -593,7 +597,7 @@ function registerDefaultActionHandlers( txMetaId = 'bridgeTxMetaId1', status = TransactionStatus.confirmed, provider = { - request: jest.fn().mockResolvedValueOnce('0xreceipt1'), + request: jest.fn().mockResolvedValue('0xreceipt1'), sendAsync: jest.fn(), send: jest.fn(), }, @@ -769,6 +773,7 @@ describe('BridgeStatusController', () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); + // jest.spyOn(historyUtils, 'isHistoryItemTooOld').mockReturnValue(false); }); describe('constructor', () => { @@ -796,6 +801,9 @@ describe('BridgeStatusController', () => { bridgeStatusUtils, 'fetchBridgeTxStatus', ); + const provider = { + request: jest.fn().mockResolvedValueOnce('txReceipt1'), + }; await withController( { @@ -804,26 +812,458 @@ describe('BridgeStatusController', () => { txHistory: { ...MockTxHistory.getPending(), ...MockTxHistory.getUnknown(), - ...MockTxHistory.getPendingSwap(), + ...MockTxHistory.getPendingSwap({ + srcTxHash: '0xswapSrcTxHash', + }), + ...MockTxHistory.getInitNoSrcTxHash({ + txMetaId: 'oldBridgeTxMetaId', + srcTxHash: '0xoldSrcTxHash', + }), }, }, fetchFn: jest .fn() .mockResolvedValueOnce(MockStatusResponse.getPending()) - .mockResolvedValueOnce(MockStatusResponse.getComplete()), + .mockResolvedValueOnce(MockStatusResponse.getComplete()) + .mockResolvedValueOnce(MockStatusResponse.getPending()), }, }, async ({ controller, rootMessenger }) => { - registerDefaultActionHandlers(rootMessenger); + registerDefaultActionHandlers(rootMessenger, { + provider, + }); + const initialStatuses = { + bridgeTxMetaId1: 'PENDING', + bridgeTxMetaId2: 'UNKNOWN', + swapTxMetaId1: 'PENDING', + oldBridgeTxMetaId: 'PENDING', + }; + expect( + Object.entries(controller.state.txHistory).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.status.status, + }), + {}, + ), + ).toStrictEqual(initialStatuses); + + expect( + Object.entries(controller.state.txHistory).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.status.srcChain.txHash, + }), + {}, + ), + ).toMatchInlineSnapshot(` + { + "bridgeTxMetaId1": "0xsrcTxHash1", + "bridgeTxMetaId2": "0xsrcTxHash2", + "oldBridgeTxMetaId": "0xoldSrcTxHash", + "swapTxMetaId1": "0xswapSrcTxHash", + } + `); + jest.advanceTimersByTime(10000); await flushPromises(); // Assertions - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(3); + expect( + provider.request.mock.calls.flatMap((call) => + call.flatMap((c) => c.params), + ), + ).toMatchInlineSnapshot(`[]`); + + expect(controller.state.txHistory.bridgeTxMetaId1.status.status).toBe( + StatusTypes.PENDING, + ); + expect(controller.state.txHistory.bridgeTxMetaId2.status.status).toBe( + StatusTypes.COMPLETE, + ); + expect(controller.state.txHistory.swapTxMetaId1.status.status).toBe( + StatusTypes.PENDING, + ); + expect( + controller.state.txHistory.oldBridgeTxMetaId.status.status, + ).toBe(StatusTypes.PENDING); controller.stopAllPolling(); }, ); }); + + it.each([ + { + title: 'tx hash, provider returns receipt', + txHash: '0xsrcTxHash2', + providerAction: async () => await Promise.resolve('txReceipt1'), + expectedFetchTxStatusCalls: 1, + }, + { + title: 'tx hash, provider returns no receipt', + txHash: '0xsrcTxHash2', + providerAction: async () => await Promise.resolve(), + expectedFetchTxStatusCalls: 1, + }, + { + title: 'tx hash, provider throws error', + txHash: '0xsrcTxHash2', + providerAction: async () => + await Promise.reject(new Error('Provider error')), + expectedFetchTxStatusCalls: 1, + }, + { + title: 'no tx hash, has txMeta', + txMeta: { + id: 'txMetaId2', + hash: '0xsrcTxHash3', + }, + expectedFetchTxStatusCalls: 1, + }, + { + title: 'no tx hash, no txMeta', + txMeta: { + id: 'undefined', + }, + }, + { + title: 'no txHash, no txMeta, provider returns no receipt', + txMeta: { + id: 'undefined', + }, + providerAction: async () => await Promise.resolve(), + }, + { + title: 'no txHash, no txMeta, provider returns receipt', + txMeta: { + id: 'undefined', + }, + providerAction: async () => await Promise.resolve('txReceipt1'), + }, + { + title: 'no txHash, no txMeta, provider throws error', + txMeta: { + id: 'undefined', + }, + providerAction: async () => + await Promise.reject(new Error('Provider error')), + }, + ])( + 'when history has $title and is older than 2 days', + async ({ + txHash = 'undefined', + providerAction = () => Promise.resolve(), + txMeta, + expectedFetchTxStatusCalls = 0, + }) => { + // Setup + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + const provider = { + request: jest + .fn() + .mockImplementationOnce(async () => await providerAction()), + }; + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementationOnce(() => jest.fn()); + + const [historyKey, txHistoryItem] = Object.entries( + MockTxHistory.getPending({ + txMetaId: txMeta?.id ?? 'unknownTxMetaId1', + srcTxHash: txHash, + }), + )[0]; + + const startTime = + Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS - 1000; + + await withController( + { + options: { + state: { + txHistory: { + [historyKey]: { + ...txHistoryItem, + attempts: { + counter: MAX_ATTEMPTS + 1, + lastAttemptTime: Date.now() - 1280000 - 1000, + }, + startTime, + }, + }, + }, + fetchFn: jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()), + }, + }, + async ({ controller, rootMessenger }) => { + const stopPollingSpy = jest.spyOn( + controller, + 'stopPollingByPollingToken', + ); + const messengerCallSpy = jest.spyOn(rootMessenger, 'call'); + + registerDefaultActionHandlers(rootMessenger, { + provider, + txMetaId: txMeta?.id === 'undefined' ? undefined : txMeta?.id, + txHash: txMeta?.hash, + }); + + controller.startPolling({ bridgeTxMetaId: historyKey }); + expect( + controller.state.txHistory[historyKey].status.status, + ).toStrictEqual(StatusTypes.PENDING); + + jest.advanceTimersByTime(DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS); + await flushPromises(); + + // Assertions + const fetchTxStatusCalls = fetchBridgeTxStatusSpy.mock.calls.length; + const providerParams = provider.request.mock.calls.flatMap((call) => + call.flatMap((c) => c.params), + ); + const expectedTxHistory = Boolean( + controller.state.txHistory[historyKey], + ); + const expectedStatus = + controller.state.txHistory[historyKey]?.status.status; + const stopPollingCalls = stopPollingSpy.mock.calls; + const expectedAttempts = + controller.state.txHistory[historyKey]?.attempts?.counter; + const consoleWarnCalls = consoleWarnSpy.mock.calls; + const expectedMetric = messengerCallSpy.mock.calls; + + const sharedResults = { + expectedMetric, + fetchTxStatusCalls, + }; + expect(sharedResults).toStrictEqual({ + expectedMetric: [], + fetchTxStatusCalls: expectedFetchTxStatusCalls, + }); + + const results = { + expectedTxHash: + controller.state.txHistory[historyKey]?.status.srcChain.txHash, + stopPollingCalls, + expectedTxHistory, + expectedStatus, + expectedAttempts, + consoleWarnCalls, + providerParams, + }; + expect(results).toMatchSnapshot(); + }, + ); + }, + ); + + describe.each([ + { + title: 'tx hash, provider returns receipt', + txHash: '0xsrcTxHash2', + providerAction: async () => await Promise.resolve('txReceipt1'), + }, + { + title: 'tx hash, provider returns no receipt', + txHash: '0xsrcTxHash2', + providerAction: async () => await Promise.resolve(), + }, + { + title: 'tx hash, provider throws error', + txHash: '0xsrcTxHash2', + providerAction: async () => + await Promise.reject(new Error('Provider error')), + }, + { + title: 'no tx hash, has txMeta', + txMeta: { + id: 'txMetaId2', + hash: '0xsrcTxHash3', + }, + }, + { + title: 'no tx hash, no txMeta', + txMeta: { + id: 'undefined', + }, + // retry: true, + }, + { + title: 'no txHash, no txMeta, provider returns no receipt', + txMeta: { + id: 'undefined', + }, + providerAction: async () => await Promise.resolve(), + // retry: true, + }, + { + title: 'no txHash, no txMeta, provider returns receipt', + txMeta: { + id: 'undefined', + }, + providerAction: async () => await Promise.resolve('txReceipt1'), + // retry: true, + }, + { + title: 'no txHash, no txMeta, provider throws error', + txMeta: { + id: 'undefined', + }, + providerAction: async () => + await Promise.reject(new Error('Provider error')), + // retry: true, + }, + ])( + 'has $title', + ({ + txHash = 'undefined', + providerAction = () => Promise.resolve(), + txMeta, + }) => { + it.each([ + { + title: 'polling every 10s', + attempts: { + counter: MAX_ATTEMPTS - 2, + lastAttemptTime: Date.now() - 160000, + }, + startTime: + Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS + 320000, + }, + { + title: 'max attempts reached', + attempts: { + counter: MAX_ATTEMPTS - 1, + lastAttemptTime: Date.now() - 320000, + }, + startTime: + Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS + 320000, + }, + { + title: 'exponentially backing off', + startTime: + Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS + 3200000, + }, + { + title: 'too old', + startTime: + Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS - 320000, + }, + ])('and $title', async ({ attempts }) => { + // Setup + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + const provider = { + request: jest + .fn() + .mockImplementationOnce(async () => await providerAction()), + }; + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementationOnce(() => jest.fn()); + + const [historyKey, txHistoryItem] = Object.entries( + MockTxHistory.getPending({ + txMetaId: txMeta?.id === 'undefined' ? undefined : txMeta?.id, + srcTxHash: txHash, + }), + )[0]; + + await withController( + { + options: { + state: { + txHistory: { + [historyKey]: { + ...txHistoryItem, + attempts: attempts ?? { + counter: MAX_ATTEMPTS + 1, + lastAttemptTime: Date.now() - 1280000 - 1000, + }, + startTime: + Date.now() - + DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS + + 320000, + }, + }, + }, + fetchFn: jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()), + }, + }, + async ({ controller, rootMessenger }) => { + const stopPollingSpy = jest.spyOn( + controller, + 'stopPollingByPollingToken', + ); + const messengerCallSpy = jest.spyOn(rootMessenger, 'call'); + + registerDefaultActionHandlers(rootMessenger, { + provider, + txMetaId: txMeta?.id, + txHash: txMeta?.hash, + }); + + controller.startPolling({ bridgeTxMetaId: historyKey }); + expect( + controller.state.txHistory[historyKey].status.status, + ).toStrictEqual(StatusTypes.PENDING); + + jest.advanceTimersByTime(DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS); + await flushPromises(); + + // Assertions + const fetchTxStatusCalls = + fetchBridgeTxStatusSpy.mock.calls.length; + const providerParams = provider.request.mock.calls.flatMap( + (call) => call.flatMap((c) => c.params), + ); + const expectedTxHistory = Boolean( + controller.state.txHistory[historyKey], + ); + const expectedStatus = + controller.state.txHistory[historyKey]?.status.status; + const stopPollingCalls = stopPollingSpy.mock.calls; + const consoleWarnCalls = consoleWarnSpy.mock.calls; + const expectedMetric = messengerCallSpy.mock.calls; + const expectedAttempts = + controller.state.txHistory[historyKey]?.attempts?.counter; + const expectedTxHash = + controller.state.txHistory[historyKey]?.status.srcChain.txHash; + + expect({ + expectedMetric, + providerParams, + }).toStrictEqual({ + expectedMetric: [], + providerParams: [], + }); + + expect({ + expectedAttempts, + expectedStatus, + expectedTxHistory, + expectedTxHash, + stopPollingCalls, + fetchTxStatusCalls, + consoleWarnCalls, + }).toMatchSnapshot('pending history item'); + }, + ); + }); + }, + ); }); describe('startPolling - error handling', () => { @@ -932,7 +1372,7 @@ describe('BridgeStatusController', () => { // Assertions expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(MAX_ATTEMPTS); expect( - controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + controller.state.txHistory.bridgeTxMetaId1?.attempts?.counter, ).toBe(MAX_ATTEMPTS); // Verify polling stops after max attempts - even with a long wait, no more calls From 2d5a957f833a08a48387b98e81a6eba7f8b47798 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 20 Apr 2026 11:04:26 -0700 Subject: [PATCH 10/19] refactor: handleFetchFailure --- .../src/utils/metrics/constants.ts | 1 + .../bridge-controller/src/utils/validators.ts | 1 + .../src/bridge-status-controller.ts | 137 +++++++++++------- .../src/utils/history.ts | 91 +++++++++++- .../src/utils/metrics.test.ts | 6 +- .../src/utils/metrics.ts | 34 ++++- .../src/utils/network.ts | 23 ++- 7 files changed, 224 insertions(+), 69 deletions(-) diff --git a/packages/bridge-controller/src/utils/metrics/constants.ts b/packages/bridge-controller/src/utils/metrics/constants.ts index 088d4bdb8e7..9e6c7265084 100644 --- a/packages/bridge-controller/src/utils/metrics/constants.ts +++ b/packages/bridge-controller/src/utils/metrics/constants.ts @@ -27,6 +27,7 @@ export enum UnifiedSwapBridgeEventName { export enum PollingStatus { MaxPollingReached = 'max_polling_reached', + StaleTransactionHash = 'stale_transaction_hash', ManuallyRestarted = 'manually_restarted', } diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 73603b2ee9b..5e21a41779a 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -189,6 +189,7 @@ export const PlatformConfigSchema = type({ * Array of chain objects ordered by preference/ranking */ chainRanking: ChainRankingSchema, + maxPendingHistoryItemAgeMs: optional(number()), }); export const validateFeatureFlagsResponse = ( diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 3e9295846ef..df1942c32df 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -63,6 +63,9 @@ import { getMatchingHistoryEntryForTxMeta, rekeyHistoryItemInState, shouldPollHistoryItem, + shouldWaitForSrcTxHash, + incrementPollingAttempts, + isHistoryItemTooOld, } from './utils/history'; import { getIntentFromQuote, @@ -80,6 +83,7 @@ import { getEVMTxPropertiesFromTransactionMeta, getTxStatusesFromHistory, getPreConfirmationPropertiesFromQuote, + getPollingStatusUpdatedProperties, } from './utils/metrics'; import { getNetworkClientIdByChainId, @@ -610,71 +614,71 @@ export class BridgeStatusController extends StaticIntervalPollingController { - const { attempts } = this.state.txHistory[bridgeTxMetaId]; - - const newAttempts = attempts - ? { - counter: attempts.counter + 1, - lastAttemptTime: Date.now(), - } - : { - counter: 1, - lastAttemptTime: Date.now(), - }; - - // If we've failed too many times, stop polling for the tx + readonly #handleFetchFailure = async ( + bridgeTxMetaId: string, + ): Promise => { + // Increment the polling attempts counter + const newAttempts = incrementPollingAttempts( + this.state.txHistory[bridgeTxMetaId], + ); + this.#updateHistoryItem({ + historyKey: bridgeTxMetaId, + attempts: newAttempts, + }); + // Continue polling every 10s until the max attempts is reached + if (newAttempts.counter < MAX_ATTEMPTS) { + return; + } const pollingToken = this.#pollingTokensByTxMetaId[bridgeTxMetaId]; - if (newAttempts.counter >= MAX_ATTEMPTS && pollingToken) { - this.stopPollingByPollingToken(pollingToken); - delete this.#pollingTokensByTxMetaId[bridgeTxMetaId]; - // Track max polling reached event - const historyItem = this.state.txHistory[bridgeTxMetaId]; - if (historyItem && !historyItem.featureId) { - const selectedAccount = getAccountByAddress( - this.messenger, - historyItem.account, - ); - const requestParams = getRequestParamFromHistory(historyItem); - const requestMetadata = getRequestMetadataFromHistory( - historyItem, - selectedAccount, - ); - const { security_warnings: _, ...metadataWithoutWarnings } = - requestMetadata; + // After the attempts exceed the max, keep polling with exponential backoff + // If the historyItem age is less than the configured maxPendingHistoryItemAgeMs flag, wait for a valid srcTxHash + if ( + await shouldWaitForSrcTxHash( + this.messenger, + this.state.txHistory[bridgeTxMetaId], + ) + ) { + return; + } - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.PollingStatusUpdated, - bridgeTxMetaId, - { - ...getTradeDataFromHistory(historyItem), - ...getPriceImpactFromQuote(historyItem.quote), - ...metadataWithoutWarnings, - chain_id_source: requestParams.chain_id_source, - chain_id_destination: requestParams.chain_id_destination, - token_symbol_source: requestParams.token_symbol_source, - token_symbol_destination: requestParams.token_symbol_destination, - action_type: MetricsActionType.SWAPBRIDGE_V1, - polling_status: PollingStatus.MaxPollingReached, - retry_attempts: newAttempts.counter, - }, - ); + if (newAttempts.counter === MAX_ATTEMPTS) { + // If we've failed too many times, stop polling for the tx + if (pollingToken) { + this.stopPollingByPollingToken(pollingToken); + delete this.#pollingTokensByTxMetaId[bridgeTxMetaId]; } + + // Track polling status updated event + this.#trackPollingStatusUpdatedEvent( + bridgeTxMetaId, + PollingStatus.MaxPollingReached, + ); + return; } - // Update the attempts counter - this.update((state) => { - state.txHistory[bridgeTxMetaId].attempts = newAttempts; - }); + // If the src hash is invalid after the max wait time, stop polling for the tx + if (pollingToken) { + this.stopPollingByPollingToken(pollingToken); + delete this.#pollingTokensByTxMetaId[bridgeTxMetaId]; + } + + // Track polling status updated event + this.#trackPollingStatusUpdatedEvent( + bridgeTxMetaId, + PollingStatus.StaleTransactionHash, + ); + + // Delete the history item so polling doesn't start over on the next restart + this.#deleteHistoryItem(bridgeTxMetaId); }; readonly #fetchBridgeTxStatus = async ({ @@ -819,7 +823,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + this.update((currentState) => { + delete currentState.txHistory[historyKey]; + }); + }; + // Wipes the bridge status for the given address and chainId // Will match only source chainId to the selectedChainId readonly #wipeBridgeStatusByChainId = ( @@ -1485,6 +1495,25 @@ export class BridgeStatusController extends StaticIntervalPollingController { + // Track polling status updated event + const historyItem = this.state.txHistory[historyKey]; + if (historyItem && !historyItem.featureId) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.PollingStatusUpdated, + historyKey, + getPollingStatusUpdatedProperties( + this.messenger, + pollingStatus, + historyItem, + ), + ); + } + }; + /** * Tracks post-submission events for a cross-chain swap based on the history item * diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index c8ef0176ef4..4b8979ed743 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -12,12 +12,8 @@ import type { BridgeStatusControllerState, StartPollingForBridgeTxStatusArgsSerialized, } from '../types'; -import { MAX_PENDING_HISTORY_ITEM_AGE_MS } from '../constants'; -import { - getTransactionMetaByHash, - getTransactionMetaById, -} from './transaction'; import { getNetworkClientByChainId } from './network'; +import { getMaxPendingHistoryItemAgeMs } from './feature-flags'; export const rekeyHistoryItemInState = ( state: BridgeStatusControllerState, @@ -194,3 +190,88 @@ export const shouldPollHistoryItem = ( return [isBridgeTx, isIntent, isTronTx].some(Boolean); }; + +export const isHistoryItemTooOld = ( + messenger: BridgeStatusControllerMessenger, + historyItem: BridgeHistoryItem, +): boolean => { + const maxPendingHistoryItemAgeMs = getMaxPendingHistoryItemAgeMs(messenger); + + const isWithinMaxPendingHistoryItemAgeMs = historyItem.startTime + ? Date.now() - historyItem.startTime <= maxPendingHistoryItemAgeMs + : false; + + return !isWithinMaxPendingHistoryItemAgeMs; +}; + +/* + * Checks if a pending history item is older than 2 days and does not have a valid tx hash + * + * @param messenger - The messenger to use to get the transaction meta by hash or id + * @param historyItem - The history item to check + * + * @returns true if the src tx hash is valid or we should still wait for it, false otherwise + */ +export const shouldWaitForSrcTxHash = async ( + messenger: BridgeStatusControllerMessenger, + historyItem: BridgeHistoryItem, +): Promise => { + if (isHistoryItemTooOld(messenger, historyItem)) { + return false; + } + + if (isNonEvmChainId(historyItem.quote.srcChainId)) { + return true; + } + + // Otherwise check if the tx has been mined on chain + const provider = getNetworkClientByChainId( + messenger, + historyItem.quote.srcChainId, + ); + // When this happens it means the network was disabled while the tx was pending + if (!provider) { + return false; + } + + if (!historyItem.status.srcChain.txHash) { + return false; + } + + // console.warn( + // '======historyItem.status.srcChain.txHash', + // historyItem.status.srcChain.txHash, + // historyItem.txMetaId, + // ); + + return provider + .request({ + method: 'eth_getTransactionReceipt', + params: [historyItem.status.srcChain.txHash], + }) + .then((txReceipt) => { + if (txReceipt) { + return true; + } + return false; + }) + .catch(() => { + return false; + }); +}; + +export const incrementPollingAttempts = ( + historyItem: BridgeHistoryItem, +): NonNullable => { + const { attempts } = historyItem; + const newAttempts = attempts + ? { + counter: attempts.counter + 1, + lastAttemptTime: Date.now(), + } + : { + counter: 1, + lastAttemptTime: Date.now(), + }; + return newAttempts; +}; diff --git a/packages/bridge-status-controller/src/utils/metrics.test.ts b/packages/bridge-status-controller/src/utils/metrics.test.ts index 5eaf1eb01ae..e276f9318e1 100644 --- a/packages/bridge-status-controller/src/utils/metrics.test.ts +++ b/packages/bridge-status-controller/src/utils/metrics.test.ts @@ -1049,7 +1049,7 @@ describe('metrics utils', () => { it('should return correct properties for a successful swap transaction', () => { const result = getEVMTxPropertiesFromTransactionMeta(mockTransactionMeta); expect(result).toStrictEqual({ - error_message: '', + error_message: 'Transaction submitted', chain_id_source: 'eip155:1', chain_id_destination: 'eip155:1', token_symbol_source: 'ETH', @@ -1086,14 +1086,14 @@ describe('metrics utils', () => { ...mockTransactionMeta, status: TransactionStatus.failed, error: { - message: 'Transaction failed', + message: 'Error message', name: 'Error', } as TransactionError, }; const result = getEVMTxPropertiesFromTransactionMeta( failedTransactionMeta, ); - expect(result.error_message).toBe('Transaction failed'); + expect(result.error_message).toBe('Transaction failed. Error message'); expect(result.source_transaction).toBe('FAILED'); }); diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index d3c2b88e487..3372265f5bf 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -25,6 +25,7 @@ import type { RequestParams, TradeData, RequestMetadata, + PollingStatus, } from '@metamask/bridge-controller'; import { TransactionStatus, @@ -34,12 +35,16 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { CaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import type { BridgeHistoryItem } from '../types'; +import type { + BridgeHistoryItem, + BridgeStatusControllerMessenger, +} from '../types'; import { calcActualGasUsed } from './gas'; import { getActualBridgeReceivedAmount, getActualSwapReceivedAmount, } from './swap-received-amount'; +import { getAccountByAddress } from './accounts'; export const getTxStatusesFromHistory = ({ status, @@ -323,3 +328,30 @@ export const getEVMTxPropertiesFromTransactionMeta = ( action_type: MetricsActionType.SWAPBRIDGE_V1, }; }; + +export const getPollingStatusUpdatedProperties = ( + messenger: BridgeStatusControllerMessenger, + pollingStatus: PollingStatus, + historyItem: BridgeHistoryItem, +) => { + const selectedAccount = getAccountByAddress(messenger, historyItem.account); + const requestParams = getRequestParamFromHistory(historyItem); + const requestMetadata = getRequestMetadataFromHistory( + historyItem, + selectedAccount, + ); + const { security_warnings: _, ...metadataWithoutWarnings } = requestMetadata; + + return { + ...getTradeDataFromHistory(historyItem), + ...getPriceImpactFromQuote(historyItem.quote), + ...metadataWithoutWarnings, + chain_id_source: requestParams.chain_id_source, + chain_id_destination: requestParams.chain_id_destination, + token_symbol_source: requestParams.token_symbol_source, + token_symbol_destination: requestParams.token_symbol_destination, + action_type: MetricsActionType.SWAPBRIDGE_V1, + polling_status: pollingStatus, + retry_attempts: historyItem.attempts?.counter ?? 0, + }; +}; diff --git a/packages/bridge-status-controller/src/utils/network.ts b/packages/bridge-status-controller/src/utils/network.ts index 7a81d8e62d9..4e89cb22c3a 100644 --- a/packages/bridge-status-controller/src/utils/network.ts +++ b/packages/bridge-status-controller/src/utils/network.ts @@ -1,10 +1,8 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { - formatChainIdToHex, - GenericQuoteRequest, -} from '@metamask/bridge-controller'; - -import { BridgeStatusControllerMessenger } from '../types'; +import { formatChainIdToHex } from '@metamask/bridge-controller'; +import type { GenericQuoteRequest } from '@metamask/bridge-controller'; +import type { NetworkClient } from '@metamask/network-controller'; +import type { BridgeStatusControllerMessenger } from '../types'; export const getSelectedChainId = ( messenger: BridgeStatusControllerMessenger, @@ -29,3 +27,16 @@ export const getNetworkClientIdByChainId = ( hexChainId, ); }; + +export const getNetworkClientByChainId = ( + messenger: BridgeStatusControllerMessenger, + chainId: GenericQuoteRequest['srcChainId'], +): NetworkClient['provider'] => { + const networkClientId = getNetworkClientIdByChainId(messenger, chainId); + + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + return networkClient.provider; +}; From 84bc75d90bc995a7e7626fb21e9a5e16b298a7b4 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 20 Apr 2026 11:31:16 -0700 Subject: [PATCH 11/19] fix: skip metrics for perps tx --- .../bridge-status-controller.test.ts.snap | 21 ++++ .../src/bridge-status-controller.test.ts | 100 ++++++++++-------- 2 files changed, 74 insertions(+), 47 deletions(-) diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 40a9c0cb263..06cc0ab2ac1 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -627,6 +627,9 @@ exports[`BridgeStatusController constructor when history has tx hash, provider t exports[`BridgeStatusController startPollingForBridgeTxStatus emits bridgeTransactionFailed event when the status response is failed 1`] = ` [ + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], @@ -810,6 +813,9 @@ exports[`BridgeStatusController startPollingForBridgeTxStatus stops polling when [ "TransactionController:getState", ], + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], @@ -6525,12 +6531,21 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should start polling for bridge tx if status response is invalid 1`] = ` [ + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], @@ -6967,6 +6982,9 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran exports[`BridgeStatusController wipeBridgeStatus wipes the bridge status for the given address 1`] = ` [ + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], @@ -6981,6 +6999,9 @@ exports[`BridgeStatusController wipeBridgeStatus wipes the bridge status for the "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Completed", ], + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index b2369ae8ef8..093fa639271 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -401,6 +401,7 @@ const MockTxHistory = { destChainId = 10, featureId = undefined, attempts = undefined as BridgeHistoryItem['attempts'], + startTime = 1729964825189, } = {}): Record => ({ [txMetaId]: { txMetaId, @@ -408,7 +409,7 @@ const MockTxHistory = { originalTransactionId: txMetaId, batchId, quote: getMockQuote({ srcChainId, destChainId }), - startTime: 1729964825189, + startTime, estimatedProcessingTimeInSeconds: 15, slippagePercentage: 0, account, @@ -480,13 +481,14 @@ const MockTxHistory = { srcChainId = 42161, destChainId = 42161, featureId = undefined, + startTime = 1729964825189, } = {}): Record => ({ [txMetaId]: { txMetaId, actionId, originalTransactionId: txMetaId, quote: getMockQuote({ srcChainId, destChainId }), - startTime: 1729964825189, + startTime, estimatedProcessingTimeInSeconds: 15, slippagePercentage: 0, account, @@ -5124,7 +5126,9 @@ describe('BridgeStatusController', () => { addTransactionBatchFn: jest.fn(), state: { txHistory: { - ...MockTxHistory.getPending(), + ...MockTxHistory.getPending({ + startTime: Date.now() - 1000, + }), ...MockTxHistory.getPendingSwap(), ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1WithApproval', @@ -5133,11 +5137,13 @@ describe('BridgeStatusController', () => { ...MockTxHistory.getPendingSwap({ txMetaId: 'perpsSwapTxMetaId1', featureId: FeatureId.PERPS as never, + startTime: Date.now() - 1000, }), ...MockTxHistory.getPending({ txMetaId: 'perpsBridgeTxMetaId1', srcTxHash: '0xperpsSrcTxHash1', featureId: FeatureId.PERPS as never, + startTime: Date.now() - 1000, }), // ActionId-keyed entries for pre-submission failure tests 'pre-submission-action-id': { @@ -5655,9 +5661,15 @@ describe('BridgeStatusController', () => { expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` [ + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], @@ -5706,9 +5718,15 @@ describe('BridgeStatusController', () => { expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` [ + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], @@ -5840,9 +5858,6 @@ describe('BridgeStatusController', () => { consoleFnSpy = jest .spyOn(console, 'error') .mockImplementationOnce(jest.fn()); - jest.spyOn(Date, 'now').mockImplementation(() => { - return 1729964825189; - }); consoleFnSpy.mockImplementationOnce(jest.fn()); messengerCallSpy.mockReturnValueOnce({ @@ -5861,6 +5876,21 @@ describe('BridgeStatusController', () => { ); }); + messengerCallSpy.mockReturnValueOnce({ + remoteFeatureFlags: { + bridgeConfig: { + maxPendingHistoryItemAgeMs: + DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS, + }, + }, + cacheTimestamp: Date.now(), + }); + messengerCallSpy.mockImplementationOnce(() => { + throw new Error( + 'AuthenticationController:getBearerToken not implemented', + ); + }); + mockFetchFn.mockClear(); mockFetchFn.mockResolvedValueOnce( MockStatusResponse.getComplete({ srcTxHash: '0xperpsSrcTxHash1' }), @@ -5887,54 +5917,16 @@ describe('BridgeStatusController', () => { expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` [ [ - "AuthenticationController:getBearerToken", + "RemoteFeatureFlagController:getState", ], [ "AuthenticationController:getBearerToken", ], [ - "AccountsController:getAccountByAddress", - "0xaccount1", + "RemoteFeatureFlagController:getState", ], [ - "TransactionController:getState", - ], - [ - "BridgeController:trackUnifiedSwapBridgeEvent", - "Unified SwapBridge Completed", - { - "action_type": "swapbridge-v1", - "actual_time_minutes": 0, - "allowance_reset_transaction": undefined, - "approval_transaction": "COMPLETE", - "chain_id_destination": "eip155:10", - "chain_id_source": "eip155:42161", - "custom_slippage": true, - "destination_transaction": "COMPLETE", - "gas_included": false, - "gas_included_7702": false, - "is_hardware_wallet": false, - "location": "Main View", - "price_impact": 0, - "provider": "lifi_across", - "quote_vs_execution_ratio": 0, - "quoted_time_minutes": 0.25, - "quoted_vs_used_gas_ratio": 0, - "security_warnings": [], - "slippage_limit": 0, - "source_transaction": "COMPLETE", - "stx_enabled": false, - "swap_type": "crosschain", - "token_address_destination": "eip155:10/slip44:60", - "token_address_source": "eip155:42161/slip44:60", - "token_symbol_destination": "ETH", - "token_symbol_source": "ETH", - "usd_actual_gas": 0, - "usd_actual_return": 0, - "usd_amount_source": 0, - "usd_quoted_gas": 2.5778, - "usd_quoted_return": 0, - }, + "AuthenticationController:getBearerToken", ], ] `); @@ -5961,6 +5953,20 @@ describe('BridgeStatusController', () => { "Error getting JWT token for bridge-api request", [Error: AuthenticationController:getBearerToken not implemented], ], + [ + "Error getting JWT token for bridge-api request", + [Error: AuthenticationController:getBearerToken not implemented], + ], + [ + "======status.status", + "test-uuid-1234", + false, + ], + [ + "======status.status", + "test-uuid-1234", + true, + ], ] `); }); From 28dd78997b4d955d0b05f5731297308b164deccd Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 20 Apr 2026 14:43:59 -0700 Subject: [PATCH 12/19] chore: throw an error if history item is too old --- .../bridge-status-controller.test.ts.snap | 521 +----------- .../bridge-status-controller.intent.test.ts | 4 + .../src/bridge-status-controller.test.ts | 758 +++++++----------- .../src/bridge-status-controller.ts | 54 +- .../src/utils/bridge-status.ts | 50 +- .../src/utils/history.ts | 57 -- 6 files changed, 385 insertions(+), 1059 deletions(-) diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 06cc0ab2ac1..afb49068cc4 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -1,389 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`BridgeStatusController constructor has no tx hash, has txMeta and exponentially backing off: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no tx hash, has txMeta and max attempts reached: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no tx hash, has txMeta and polling every 10s: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no tx hash, has txMeta and too old: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no tx hash, no txMeta and exponentially backing off: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no tx hash, no txMeta and max attempts reached: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 6, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no tx hash, no txMeta and polling every 10s: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 5, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no tx hash, no txMeta and too old: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns no receipt and exponentially backing off: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns no receipt and max attempts reached: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 6, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns no receipt and polling every 10s: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 5, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns no receipt and too old: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns receipt and exponentially backing off: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns receipt and max attempts reached: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 6, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns receipt and polling every 10s: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 5, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider returns receipt and too old: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider throws error and exponentially backing off: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider throws error and max attempts reached: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 6, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider throws error and polling every 10s: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 5, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has no txHash, no txMeta, provider throws error and too old: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "fetchTxStatusCalls": 0, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider returns no receipt and exponentially backing off: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider returns no receipt and max attempts reached: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider returns no receipt and polling every 10s: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider returns no receipt and too old: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider returns receipt and exponentially backing off: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider returns receipt and max attempts reached: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider returns receipt and polling every 10s: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider returns receipt and too old: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider throws error and exponentially backing off: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider throws error and max attempts reached: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider throws error and polling every 10s: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - -exports[`BridgeStatusController constructor has tx hash, provider throws error and too old: pending history item 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "fetchTxStatusCalls": 1, - "stopPollingCalls": [], -} -`; - exports[`BridgeStatusController constructor rehydrates the tx history state 1`] = ` { "bridgeTxMetaId1": { @@ -530,106 +146,59 @@ exports[`BridgeStatusController constructor rehydrates the tx history state 1`] `; exports[`BridgeStatusController constructor when history has no tx hash, has txMeta and is older than 2 days 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "providerParams": [], - "stopPollingCalls": [], -} +[ + { + "method": "eth_getTransactionReceipt", + "params": [ + "0xsrcTxHash3", + ], + }, +] `; -exports[`BridgeStatusController constructor when history has no tx hash, no txMeta and is older than 2 days 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "providerParams": [], - "stopPollingCalls": [], -} -`; +exports[`BridgeStatusController constructor when history has no tx hash, no txMeta and is older than 2 days 1`] = `[]`; -exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider returns no receipt and is older than 2 days 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "providerParams": [], - "stopPollingCalls": [], -} -`; +exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider returns no receipt and is older than 2 days 1`] = `[]`; -exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider returns receipt and is older than 2 days 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "providerParams": [], - "stopPollingCalls": [], -} -`; +exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider returns receipt and is older than 2 days 1`] = `[]`; -exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider throws error and is older than 2 days 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": 8, - "expectedStatus": "PENDING", - "expectedTxHash": undefined, - "expectedTxHistory": true, - "providerParams": [], - "stopPollingCalls": [], -} -`; +exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider throws error and is older than 2 days 1`] = `[]`; exports[`BridgeStatusController constructor when history has tx hash, provider returns no receipt and is older than 2 days 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "providerParams": [], - "stopPollingCalls": [], -} +[ + { + "method": "eth_getTransactionReceipt", + "params": [ + "0xsrcTxHash2", + ], + }, +] `; exports[`BridgeStatusController constructor when history has tx hash, provider returns receipt and is older than 2 days 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "providerParams": [], - "stopPollingCalls": [], -} +[ + { + "method": "eth_getTransactionReceipt", + "params": [ + "0xsrcTxHash2", + ], + }, +] `; exports[`BridgeStatusController constructor when history has tx hash, provider throws error and is older than 2 days 1`] = ` -{ - "consoleWarnCalls": [], - "expectedAttempts": undefined, - "expectedStatus": "PENDING", - "expectedTxHash": "0xsrcTxHash1", - "expectedTxHistory": true, - "providerParams": [], - "stopPollingCalls": [], -} +[ + { + "method": "eth_getTransactionReceipt", + "params": [ + "0xsrcTxHash2", + ], + }, +] `; exports[`BridgeStatusController startPollingForBridgeTxStatus emits bridgeTransactionFailed event when the status response is failed 1`] = ` [ - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], @@ -813,9 +382,6 @@ exports[`BridgeStatusController startPollingForBridgeTxStatus stops polling when [ "TransactionController:getState", ], - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], @@ -6531,21 +6097,12 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should start polling for bridge tx if status response is invalid 1`] = ` [ - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], @@ -6982,9 +6539,6 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran exports[`BridgeStatusController wipeBridgeStatus wipes the bridge status for the given address 1`] = ` [ - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], @@ -6999,9 +6553,6 @@ exports[`BridgeStatusController wipeBridgeStatus wipes the bridge status for the "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Completed", ], - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index b77fc6e8aac..ea069ea9e87 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -1231,6 +1231,7 @@ describe('BridgeStatusController (target uncovered branches)', () => { status: StatusTypes.PENDING, srcChain: { chainId: 1, txHash: '0xhash' }, }, + startTime: Date.now() - 1000, }, }, }); @@ -1247,6 +1248,9 @@ describe('BridgeStatusController (target uncovered branches)', () => { expect(messenger.call.mock.calls).toMatchInlineSnapshot(` [ + [ + "RemoteFeatureFlagController:getState", + ], [ "AuthenticationController:getBearerToken", ], diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 093fa639271..961631beb81 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -771,32 +771,187 @@ const mockSelectedAccount = { }, }; -describe('BridgeStatusController', () => { +describe('BridgeStatusController constructor', () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); - // jest.spyOn(historyUtils, 'isHistoryItemTooOld').mockReturnValue(false); }); - describe('constructor', () => { - it('should setup correctly', async () => { - await withController(async ({ controller }) => { - expect(controller.state).toStrictEqual(EMPTY_INIT_STATE); - }); + it('should setup correctly', async () => { + await withController(async ({ controller }) => { + expect(controller.state).toStrictEqual(EMPTY_INIT_STATE); }); + }); - it('rehydrates the tx history state', async () => { - await withController( - { options: { state: { txHistory: MockTxHistory.getPending() } } }, - async ({ controller }) => { - // Assertion - expect(controller.state.txHistory).toMatchSnapshot(); - controller.stopAllPolling(); + it('rehydrates the tx history state', async () => { + await withController( + { options: { state: { txHistory: MockTxHistory.getPending() } } }, + async ({ controller }) => { + // Assertion + expect(controller.state.txHistory).toMatchSnapshot(); + controller.stopAllPolling(); + }, + ); + }); + + it('restarts polling for history items that are not complete', async () => { + // Setup + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + const provider = { + request: jest.fn().mockResolvedValueOnce('txReceipt1'), + }; + + jest.spyOn(historyUtils, 'isHistoryItemTooOld').mockReturnValue(false); + + await withController( + { + options: { + state: { + txHistory: { + ...MockTxHistory.getPending(), + ...MockTxHistory.getUnknown(), + ...MockTxHistory.getPendingSwap({ + srcTxHash: '0xswapSrcTxHash', + }), + ...MockTxHistory.getInitNoSrcTxHash({ + txMetaId: 'oldBridgeTxMetaId', + srcTxHash: '0xoldSrcTxHash', + }), + }, + }, + fetchFn: jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()) + .mockResolvedValueOnce(MockStatusResponse.getComplete()) + .mockResolvedValueOnce(MockStatusResponse.getPending()), }, - ); - }); + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger, { + provider, + }); + const initialStatuses = { + bridgeTxMetaId1: 'PENDING', + bridgeTxMetaId2: 'UNKNOWN', + swapTxMetaId1: 'PENDING', + oldBridgeTxMetaId: 'PENDING', + }; + expect( + Object.entries(controller.state.txHistory).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.status.status, + }), + {}, + ), + ).toStrictEqual(initialStatuses); + + expect( + Object.entries(controller.state.txHistory).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.status.srcChain.txHash, + }), + {}, + ), + ).toMatchInlineSnapshot(` + { + "bridgeTxMetaId1": "0xsrcTxHash1", + "bridgeTxMetaId2": "0xsrcTxHash2", + "oldBridgeTxMetaId": "0xoldSrcTxHash", + "swapTxMetaId1": "0xswapSrcTxHash", + } + `); + + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(3); + expect(provider.request.mock.calls.flat()).toMatchInlineSnapshot(`[]`); + + expect(controller.state.txHistory.bridgeTxMetaId1.status.status).toBe( + StatusTypes.PENDING, + ); + expect(controller.state.txHistory.bridgeTxMetaId2.status.status).toBe( + StatusTypes.COMPLETE, + ); + expect(controller.state.txHistory.swapTxMetaId1.status.status).toBe( + StatusTypes.PENDING, + ); + expect(controller.state.txHistory.oldBridgeTxMetaId.status.status).toBe( + StatusTypes.PENDING, + ); + controller.stopAllPolling(); + }, + ); + }); - it('restarts polling for history items that are not complete', async () => { + it.each([ + { + title: 'tx hash, provider returns receipt', + txHash: '0xsrcTxHash2', + providerAction: async () => await Promise.resolve('txReceipt1'), + expectedHistoryTxMetaId: 'unknownTxMetaId1', + }, + { + title: 'tx hash, provider returns no receipt', + txHash: '0xsrcTxHash2', + providerAction: async () => await Promise.resolve(), + }, + { + title: 'tx hash, provider throws error', + txHash: '0xsrcTxHash2', + providerAction: async () => + await Promise.reject(new Error('Provider error')), + }, + { + title: 'no tx hash, has txMeta', + txMeta: { + id: 'txMetaId2', + hash: '0xsrcTxHash3', + }, + }, + { + title: 'no tx hash, no txMeta', + txMeta: { + id: 'undefined', + }, + }, + { + title: 'no txHash, no txMeta, provider returns no receipt', + txMeta: { + id: 'undefined', + }, + providerAction: async () => await Promise.resolve(), + }, + { + title: 'no txHash, no txMeta, provider returns receipt', + txMeta: { + id: 'undefined', + }, + providerAction: async () => await Promise.resolve('txReceipt1'), + }, + { + title: 'no txHash, no txMeta, provider throws error', + txMeta: { + id: 'undefined', + }, + providerAction: async () => + await Promise.reject(new Error('Provider error')), + }, + ])( + 'when history has $title and is older than 2 days', + async ({ + txHash = 'undefined', + providerAction = () => Promise.resolve(), + txMeta, + expectedHistoryTxMetaId, + }) => { // Setup jest.useFakeTimers(); const fetchBridgeTxStatusSpy = jest.spyOn( @@ -804,468 +959,109 @@ describe('BridgeStatusController', () => { 'fetchBridgeTxStatus', ); const provider = { - request: jest.fn().mockResolvedValueOnce('txReceipt1'), + request: jest + .fn() + .mockImplementationOnce(async () => await providerAction()), }; + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementationOnce(() => jest.fn()); + + const [historyKey, txHistoryItem] = Object.entries( + MockTxHistory.getPending({ + txMetaId: txMeta?.id ?? 'unknownTxMetaId1', + srcTxHash: txHash, + }), + )[0]; + + const startTime = + Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS - 1000; await withController( { options: { state: { txHistory: { - ...MockTxHistory.getPending(), - ...MockTxHistory.getUnknown(), - ...MockTxHistory.getPendingSwap({ - srcTxHash: '0xswapSrcTxHash', - }), - ...MockTxHistory.getInitNoSrcTxHash({ - txMetaId: 'oldBridgeTxMetaId', - srcTxHash: '0xoldSrcTxHash', - }), + [historyKey]: { + ...txHistoryItem, + attempts: { + counter: MAX_ATTEMPTS + 1, + lastAttemptTime: Date.now() - 1280000 - 1000, + }, + startTime, + }, }, }, fetchFn: jest .fn() - .mockResolvedValueOnce(MockStatusResponse.getPending()) - .mockResolvedValueOnce(MockStatusResponse.getComplete()) .mockResolvedValueOnce(MockStatusResponse.getPending()), }, }, async ({ controller, rootMessenger }) => { + const stopPollingSpy = jest.spyOn( + controller, + 'stopPollingByPollingToken', + ); + const messengerCallSpy = jest.spyOn(rootMessenger, 'call'); + registerDefaultActionHandlers(rootMessenger, { provider, + txMetaId: txMeta?.id === 'undefined' ? undefined : txMeta?.id, + txHash: txMeta?.hash, }); - const initialStatuses = { - bridgeTxMetaId1: 'PENDING', - bridgeTxMetaId2: 'UNKNOWN', - swapTxMetaId1: 'PENDING', - oldBridgeTxMetaId: 'PENDING', - }; - expect( - Object.entries(controller.state.txHistory).reduce( - (acc, [key, value]) => ({ - ...acc, - [key]: value.status.status, - }), - {}, - ), - ).toStrictEqual(initialStatuses); + controller.startPolling({ bridgeTxMetaId: historyKey }); expect( - Object.entries(controller.state.txHistory).reduce( - (acc, [key, value]) => ({ - ...acc, - [key]: value.status.srcChain.txHash, - }), - {}, - ), - ).toMatchInlineSnapshot(` - { - "bridgeTxMetaId1": "0xsrcTxHash1", - "bridgeTxMetaId2": "0xsrcTxHash2", - "oldBridgeTxMetaId": "0xoldSrcTxHash", - "swapTxMetaId1": "0xswapSrcTxHash", - } - `); + controller.state.txHistory[historyKey].status.status, + ).toStrictEqual(StatusTypes.PENDING); - jest.advanceTimersByTime(10000); + jest.advanceTimersByTime(DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS); await flushPromises(); // Assertions - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(3); - expect( - provider.request.mock.calls.flatMap((call) => - call.flatMap((c) => c.params), + const fetchTxStatusCalls = fetchBridgeTxStatusSpy.mock.calls.length; + const consoleWarnCalls = consoleWarnSpy.mock.calls; + const expectedMetric = messengerCallSpy.mock.calls; + + expect({ + expectedMetric, + fetchTxStatusCalls, + historyTxMetaIdAfterFetch: + controller.state.txHistory[historyKey]?.txMetaId, + consoleWarnCalls: consoleWarnCalls.map((call) => + JSON.stringify(call), ), - ).toMatchInlineSnapshot(`[]`); + }).toStrictEqual({ + expectedMetric: [], + fetchTxStatusCalls: 0, + historyTxMetaIdAfterFetch: expectedHistoryTxMetaId, + consoleWarnCalls: ['["Failed to fetch bridge tx status",{}]'], + }); - expect(controller.state.txHistory.bridgeTxMetaId1.status.status).toBe( - StatusTypes.PENDING, + expect(controller.state.txHistory[historyKey]?.status.status).toBe( + expectedHistoryTxMetaId ? StatusTypes.PENDING : undefined, ); - expect(controller.state.txHistory.bridgeTxMetaId2.status.status).toBe( - StatusTypes.COMPLETE, - ); - expect(controller.state.txHistory.swapTxMetaId1.status.status).toBe( - StatusTypes.PENDING, + expect( + controller.state.txHistory[historyKey]?.attempts?.counter, + ).toBe(expectedHistoryTxMetaId ? MAX_ATTEMPTS + 2 : undefined); + expect(stopPollingSpy.mock.calls).toStrictEqual( + expectedHistoryTxMetaId ? [] : [['test-uuid-1234']], ); expect( - controller.state.txHistory.oldBridgeTxMetaId.status.status, - ).toBe(StatusTypes.PENDING); - controller.stopAllPolling(); + controller.state.txHistory[historyKey]?.status.srcChain.txHash, + ).toBe(expectedHistoryTxMetaId ? txHash : undefined); + expect(provider.request.mock.calls.flat()).toMatchSnapshot(); }, ); - }); - - it.each([ - { - title: 'tx hash, provider returns receipt', - txHash: '0xsrcTxHash2', - providerAction: async () => await Promise.resolve('txReceipt1'), - expectedFetchTxStatusCalls: 1, - }, - { - title: 'tx hash, provider returns no receipt', - txHash: '0xsrcTxHash2', - providerAction: async () => await Promise.resolve(), - expectedFetchTxStatusCalls: 1, - }, - { - title: 'tx hash, provider throws error', - txHash: '0xsrcTxHash2', - providerAction: async () => - await Promise.reject(new Error('Provider error')), - expectedFetchTxStatusCalls: 1, - }, - { - title: 'no tx hash, has txMeta', - txMeta: { - id: 'txMetaId2', - hash: '0xsrcTxHash3', - }, - expectedFetchTxStatusCalls: 1, - }, - { - title: 'no tx hash, no txMeta', - txMeta: { - id: 'undefined', - }, - }, - { - title: 'no txHash, no txMeta, provider returns no receipt', - txMeta: { - id: 'undefined', - }, - providerAction: async () => await Promise.resolve(), - }, - { - title: 'no txHash, no txMeta, provider returns receipt', - txMeta: { - id: 'undefined', - }, - providerAction: async () => await Promise.resolve('txReceipt1'), - }, - { - title: 'no txHash, no txMeta, provider throws error', - txMeta: { - id: 'undefined', - }, - providerAction: async () => - await Promise.reject(new Error('Provider error')), - }, - ])( - 'when history has $title and is older than 2 days', - async ({ - txHash = 'undefined', - providerAction = () => Promise.resolve(), - txMeta, - expectedFetchTxStatusCalls = 0, - }) => { - // Setup - jest.useFakeTimers(); - const fetchBridgeTxStatusSpy = jest.spyOn( - bridgeStatusUtils, - 'fetchBridgeTxStatus', - ); - const provider = { - request: jest - .fn() - .mockImplementationOnce(async () => await providerAction()), - }; - const consoleWarnSpy = jest - .spyOn(console, 'warn') - .mockImplementationOnce(() => jest.fn()); - - const [historyKey, txHistoryItem] = Object.entries( - MockTxHistory.getPending({ - txMetaId: txMeta?.id ?? 'unknownTxMetaId1', - srcTxHash: txHash, - }), - )[0]; - - const startTime = - Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS - 1000; - - await withController( - { - options: { - state: { - txHistory: { - [historyKey]: { - ...txHistoryItem, - attempts: { - counter: MAX_ATTEMPTS + 1, - lastAttemptTime: Date.now() - 1280000 - 1000, - }, - startTime, - }, - }, - }, - fetchFn: jest - .fn() - .mockResolvedValueOnce(MockStatusResponse.getPending()), - }, - }, - async ({ controller, rootMessenger }) => { - const stopPollingSpy = jest.spyOn( - controller, - 'stopPollingByPollingToken', - ); - const messengerCallSpy = jest.spyOn(rootMessenger, 'call'); - - registerDefaultActionHandlers(rootMessenger, { - provider, - txMetaId: txMeta?.id === 'undefined' ? undefined : txMeta?.id, - txHash: txMeta?.hash, - }); - - controller.startPolling({ bridgeTxMetaId: historyKey }); - expect( - controller.state.txHistory[historyKey].status.status, - ).toStrictEqual(StatusTypes.PENDING); - - jest.advanceTimersByTime(DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS); - await flushPromises(); - - // Assertions - const fetchTxStatusCalls = fetchBridgeTxStatusSpy.mock.calls.length; - const providerParams = provider.request.mock.calls.flatMap((call) => - call.flatMap((c) => c.params), - ); - const expectedTxHistory = Boolean( - controller.state.txHistory[historyKey], - ); - const expectedStatus = - controller.state.txHistory[historyKey]?.status.status; - const stopPollingCalls = stopPollingSpy.mock.calls; - const expectedAttempts = - controller.state.txHistory[historyKey]?.attempts?.counter; - const consoleWarnCalls = consoleWarnSpy.mock.calls; - const expectedMetric = messengerCallSpy.mock.calls; - - const sharedResults = { - expectedMetric, - fetchTxStatusCalls, - }; - expect(sharedResults).toStrictEqual({ - expectedMetric: [], - fetchTxStatusCalls: expectedFetchTxStatusCalls, - }); - - const results = { - expectedTxHash: - controller.state.txHistory[historyKey]?.status.srcChain.txHash, - stopPollingCalls, - expectedTxHistory, - expectedStatus, - expectedAttempts, - consoleWarnCalls, - providerParams, - }; - expect(results).toMatchSnapshot(); - }, - ); - }, - ); - - describe.each([ - { - title: 'tx hash, provider returns receipt', - txHash: '0xsrcTxHash2', - providerAction: async () => await Promise.resolve('txReceipt1'), - }, - { - title: 'tx hash, provider returns no receipt', - txHash: '0xsrcTxHash2', - providerAction: async () => await Promise.resolve(), - }, - { - title: 'tx hash, provider throws error', - txHash: '0xsrcTxHash2', - providerAction: async () => - await Promise.reject(new Error('Provider error')), - }, - { - title: 'no tx hash, has txMeta', - txMeta: { - id: 'txMetaId2', - hash: '0xsrcTxHash3', - }, - }, - { - title: 'no tx hash, no txMeta', - txMeta: { - id: 'undefined', - }, - // retry: true, - }, - { - title: 'no txHash, no txMeta, provider returns no receipt', - txMeta: { - id: 'undefined', - }, - providerAction: async () => await Promise.resolve(), - // retry: true, - }, - { - title: 'no txHash, no txMeta, provider returns receipt', - txMeta: { - id: 'undefined', - }, - providerAction: async () => await Promise.resolve('txReceipt1'), - // retry: true, - }, - { - title: 'no txHash, no txMeta, provider throws error', - txMeta: { - id: 'undefined', - }, - providerAction: async () => - await Promise.reject(new Error('Provider error')), - // retry: true, - }, - ])( - 'has $title', - ({ - txHash = 'undefined', - providerAction = () => Promise.resolve(), - txMeta, - }) => { - it.each([ - { - title: 'polling every 10s', - attempts: { - counter: MAX_ATTEMPTS - 2, - lastAttemptTime: Date.now() - 160000, - }, - startTime: - Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS + 320000, - }, - { - title: 'max attempts reached', - attempts: { - counter: MAX_ATTEMPTS - 1, - lastAttemptTime: Date.now() - 320000, - }, - startTime: - Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS + 320000, - }, - { - title: 'exponentially backing off', - startTime: - Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS + 3200000, - }, - { - title: 'too old', - startTime: - Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS - 320000, - }, - ])('and $title', async ({ attempts }) => { - // Setup - jest.useFakeTimers(); - const fetchBridgeTxStatusSpy = jest.spyOn( - bridgeStatusUtils, - 'fetchBridgeTxStatus', - ); - const provider = { - request: jest - .fn() - .mockImplementationOnce(async () => await providerAction()), - }; - const consoleWarnSpy = jest - .spyOn(console, 'warn') - .mockImplementationOnce(() => jest.fn()); - - const [historyKey, txHistoryItem] = Object.entries( - MockTxHistory.getPending({ - txMetaId: txMeta?.id === 'undefined' ? undefined : txMeta?.id, - srcTxHash: txHash, - }), - )[0]; + }, + ); +}); - await withController( - { - options: { - state: { - txHistory: { - [historyKey]: { - ...txHistoryItem, - attempts: attempts ?? { - counter: MAX_ATTEMPTS + 1, - lastAttemptTime: Date.now() - 1280000 - 1000, - }, - startTime: - Date.now() - - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS + - 320000, - }, - }, - }, - fetchFn: jest - .fn() - .mockResolvedValueOnce(MockStatusResponse.getPending()), - }, - }, - async ({ controller, rootMessenger }) => { - const stopPollingSpy = jest.spyOn( - controller, - 'stopPollingByPollingToken', - ); - const messengerCallSpy = jest.spyOn(rootMessenger, 'call'); - - registerDefaultActionHandlers(rootMessenger, { - provider, - txMetaId: txMeta?.id, - txHash: txMeta?.hash, - }); - - controller.startPolling({ bridgeTxMetaId: historyKey }); - expect( - controller.state.txHistory[historyKey].status.status, - ).toStrictEqual(StatusTypes.PENDING); - - jest.advanceTimersByTime(DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS); - await flushPromises(); - - // Assertions - const fetchTxStatusCalls = - fetchBridgeTxStatusSpy.mock.calls.length; - const providerParams = provider.request.mock.calls.flatMap( - (call) => call.flatMap((c) => c.params), - ); - const expectedTxHistory = Boolean( - controller.state.txHistory[historyKey], - ); - const expectedStatus = - controller.state.txHistory[historyKey]?.status.status; - const stopPollingCalls = stopPollingSpy.mock.calls; - const consoleWarnCalls = consoleWarnSpy.mock.calls; - const expectedMetric = messengerCallSpy.mock.calls; - const expectedAttempts = - controller.state.txHistory[historyKey]?.attempts?.counter; - const expectedTxHash = - controller.state.txHistory[historyKey]?.status.srcChain.txHash; - - expect({ - expectedMetric, - providerParams, - }).toStrictEqual({ - expectedMetric: [], - providerParams: [], - }); - - expect({ - expectedAttempts, - expectedStatus, - expectedTxHistory, - expectedTxHash, - stopPollingCalls, - fetchTxStatusCalls, - consoleWarnCalls, - }).toMatchSnapshot('pending history item'); - }, - ); - }); - }, - ); +describe('BridgeStatusController', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.spyOn(historyUtils, 'isHistoryItemTooOld').mockReturnValue(false); }); describe('startPolling - error handling', () => { @@ -1372,10 +1168,12 @@ describe('BridgeStatusController', () => { } // Assertions - expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(MAX_ATTEMPTS); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes( + MAX_ATTEMPTS + 1, + ); expect( controller.state.txHistory.bridgeTxMetaId1?.attempts?.counter, - ).toBe(MAX_ATTEMPTS); + ).toBe(MAX_ATTEMPTS + 1); // Verify polling stops after max attempts - even with a long wait, no more calls const callCountBeforeExtraTime = @@ -1416,6 +1214,10 @@ describe('BridgeStatusController', () => { "Failed to fetch bridge tx status", [Error: Persistent error], ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], ] `); }, @@ -5400,18 +5202,21 @@ describe('BridgeStatusController', () => { it('should call getAccountByAddress when txParams.from is set', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - mockMessenger.publish('TransactionController:transactionFailed', { - error: 'tx-error', - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - networkClientId: 'eth-id', - time: Date.now(), - txParams: { from: '0xaccount1' } as unknown as TransactionParams, - type: TransactionType.bridge, - status: TransactionStatus.failed, - id: 'bridgeTxMetaId1', + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: { from: '0xaccount1' } as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'bridgeTxMetaId1', + }, }, - }); + ); expect(messengerCallSpy).toHaveBeenCalledWith( 'AccountsController:getAccountByAddress', @@ -5661,15 +5466,9 @@ describe('BridgeStatusController', () => { expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` [ - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], @@ -5718,15 +5517,9 @@ describe('BridgeStatusController', () => { expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` [ - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], - [ - "RemoteFeatureFlagController:getState", - ], [ "AuthenticationController:getBearerToken", ], @@ -5917,16 +5710,17 @@ describe('BridgeStatusController', () => { expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` [ [ - "RemoteFeatureFlagController:getState", + "AuthenticationController:getBearerToken", ], [ "AuthenticationController:getBearerToken", ], [ - "RemoteFeatureFlagController:getState", + "AccountsController:getAccountByAddress", + "0xaccount1", ], [ - "AuthenticationController:getBearerToken", + "TransactionController:getState", ], ] `); @@ -5953,20 +5747,6 @@ describe('BridgeStatusController', () => { "Error getting JWT token for bridge-api request", [Error: AuthenticationController:getBearerToken not implemented], ], - [ - "Error getting JWT token for bridge-api request", - [Error: AuthenticationController:getBearerToken not implemented], - ], - [ - "======status.status", - "test-uuid-1234", - false, - ], - [ - "======status.status", - "test-uuid-1234", - true, - ], ] `); }); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index df1942c32df..5a474d9f440 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -57,13 +57,13 @@ import { fetchBridgeTxStatus, getStatusRequestWithSrcTxHash, shouldSkipFetchDueToFetchFailures, + shouldWaitForFinalBridgeStatus, } from './utils/bridge-status'; import { getInitialHistoryItem, getMatchingHistoryEntryForTxMeta, rekeyHistoryItemInState, shouldPollHistoryItem, - shouldWaitForSrcTxHash, incrementPollingAttempts, isHistoryItemTooOld, } from './utils/history'; @@ -259,7 +259,7 @@ export class BridgeStatusController extends StaticIntervalPollingController `${bridgeApiBaseUrl}/getTxStatus`; @@ -112,6 +114,52 @@ export const shouldSkipFetchDueToFetchFailures = ( return false; }; +/* + * Checks if a pending history item is older than 2 days and does not have a valid tx hash + * + * @param messenger - The messenger to use to get the transaction meta by hash or id + * @param historyItem - The history item to check + * + * @returns true if the src tx hash is valid or we should still wait for it, false otherwise + */ +export const shouldWaitForFinalBridgeStatus = async ( + messenger: BridgeStatusControllerMessenger, + historyItem: BridgeHistoryItem, +): Promise => { + if (isNonEvmChainId(historyItem.quote.srcChainId)) { + return true; + } + + // Otherwise check if the tx has been mined on chain + const provider = getNetworkClientByChainId( + messenger, + historyItem.quote.srcChainId, + ); + // When this happens it means the network was disabled while the tx was pending + if (!provider) { + return false; + } + + if (!historyItem.status.srcChain.txHash) { + return false; + } + + return provider + .request({ + method: 'eth_getTransactionReceipt', + params: [historyItem.status.srcChain.txHash], + }) + .then((txReceipt) => { + if (txReceipt) { + return true; + } + return false; + }) + .catch(() => { + return false; + }); +}; + /** * @deprecated Use getStatusRequestWithSrcTxHash instead * @param quoteResponse - The quote response to get the status request parameters from diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index 4b8979ed743..567f0561bb6 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -12,7 +12,6 @@ import type { BridgeStatusControllerState, StartPollingForBridgeTxStatusArgsSerialized, } from '../types'; -import { getNetworkClientByChainId } from './network'; import { getMaxPendingHistoryItemAgeMs } from './feature-flags'; export const rekeyHistoryItemInState = ( @@ -204,62 +203,6 @@ export const isHistoryItemTooOld = ( return !isWithinMaxPendingHistoryItemAgeMs; }; -/* - * Checks if a pending history item is older than 2 days and does not have a valid tx hash - * - * @param messenger - The messenger to use to get the transaction meta by hash or id - * @param historyItem - The history item to check - * - * @returns true if the src tx hash is valid or we should still wait for it, false otherwise - */ -export const shouldWaitForSrcTxHash = async ( - messenger: BridgeStatusControllerMessenger, - historyItem: BridgeHistoryItem, -): Promise => { - if (isHistoryItemTooOld(messenger, historyItem)) { - return false; - } - - if (isNonEvmChainId(historyItem.quote.srcChainId)) { - return true; - } - - // Otherwise check if the tx has been mined on chain - const provider = getNetworkClientByChainId( - messenger, - historyItem.quote.srcChainId, - ); - // When this happens it means the network was disabled while the tx was pending - if (!provider) { - return false; - } - - if (!historyItem.status.srcChain.txHash) { - return false; - } - - // console.warn( - // '======historyItem.status.srcChain.txHash', - // historyItem.status.srcChain.txHash, - // historyItem.txMetaId, - // ); - - return provider - .request({ - method: 'eth_getTransactionReceipt', - params: [historyItem.status.srcChain.txHash], - }) - .then((txReceipt) => { - if (txReceipt) { - return true; - } - return false; - }) - .catch(() => { - return false; - }); -}; - export const incrementPollingAttempts = ( historyItem: BridgeHistoryItem, ): NonNullable => { From e2a7723d0326a6dd560c30d3717b6b1a578389fc Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 20 Apr 2026 15:25:03 -0700 Subject: [PATCH 13/19] test: remove redundant unit tests --- .../bridge-status-controller.intent.test.ts | 204 ------------------ 1 file changed, 204 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index ea069ea9e87..7acf911f698 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -730,172 +730,6 @@ describe('BridgeStatusController (subscriptions + bridge polling + wiping)', () jest.clearAllMocks(); }); - it.skip('tranactionStatusUpdated (failed) subscription: marks main tx as FAILED and tracks (non-rejected)', async () => { - const mockTxHistory = { - bridgeTxMetaId1: { - txMetaId: 'bridgeTxMetaId1', - originalTransactionId: 'bridgeTxMetaId1', - quote: { - srcChainId: 1, - destChainId: 10, - srcAsset: { - address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:1/slip44:60', - }, - destAsset: { assetId: 'eip155:10/slip44:60' }, - bridges: ['across'], - bridgeId: 'rango', - }, - account: '0xAccount1', - status: { - status: StatusTypes.PENDING, - srcChain: { chainId: 1, txHash: '0xsrc' }, - }, - }, - }; - const { controller, messenger } = setup({ - mockTxHistory, - }); - - const failedCb = messenger.subscribe.mock.calls.find( - ([evt]: [any]) => - evt === 'TransactionController:transactionStatusUpdated', - )?.[1]; - expect(typeof failedCb).toBe('function'); - - failedCb({ - transactionMeta: { - id: 'bridgeTxMetaId1', - type: TransactionType.bridge, - status: TransactionStatus.failed, - chainId: '0x1', - }, - }); - - controller.stopAllPolling(); - - expect(controller.state.txHistory.bridgeTxMetaId1.status.status).toBe( - StatusTypes.FAILED, - ); - - // ensure tracking was attempted - expect((messenger.call as jest.Mock).mock.calls).toStrictEqual( - expect.arrayContaining([ - expect.arrayContaining([ - 'BridgeController:trackUnifiedSwapBridgeEvent', - ]), - ]), - ); - }); - - it.skip('transactionStatusUpdated (failed) subscription: maps approval tx id back to main history item', async () => { - const mockTxHistory = { - mainTx: { - txMetaId: 'mainTx', - originalTransactionId: 'mainTx', - approvalTxId: 'approvalTx', - quote: { - srcChainId: 1, - destChainId: 10, - srcAsset: { - address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:1/slip44:60', - }, - destAsset: { - address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:60', - }, - bridges: ['cowswap'], - bridgeId: 'cowswap', - }, - account: '0xAccount1', - status: { - status: StatusTypes.PENDING, - srcChain: { chainId: 1, txHash: '0xsrc' }, - }, - }, - }; - const { controller, messenger } = setup({ - mockTxHistory, - }); - const failedCb = messenger.subscribe.mock.calls.find( - ([evt]: [any]) => - evt === 'TransactionController:transactionStatusUpdated', - )?.[1]; - - failedCb({ - transactionMeta: { - id: 'approvalTx', - type: TransactionType.bridgeApproval, - status: TransactionStatus.failed, - chainId: '0x1', - }, - }); - - controller.stopAllPolling(); - - expect(controller.state.txHistory.mainTx.status.status).toBe( - StatusTypes.FAILED, - ); - }); - - it.skip('transactionStatusUpdated (confirmed) subscription: tracks swap Completed; starts polling on bridge confirmed', async () => { - const mockTxHistory = { - bridgeConfirmed1: { - txMetaId: 'bridgeConfirmed1', - originalTransactionId: 'bridgeConfirmed1', - quote: { - srcChainId: 1, - destChainId: 10, - srcAsset: { - address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:1/slip44:60', - }, - destAsset: { - address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:60', - }, - bridges: ['cowswap'], - bridgeId: 'cowswap', - }, - account: '0xAccount1', - status: { - status: StatusTypes.PENDING, - srcChain: { chainId: 1, txHash: '0xsrc' }, - }, - }, - }; - const { messenger, controller, startPollingSpy } = setup({ - mockTxHistory, - }); - - const confirmedCb = messenger.subscribe.mock.calls.find( - ([evt]: [any]) => - evt === 'TransactionController:transactionStatusUpdated', - )?.[1]; - expect(typeof confirmedCb).toBe('function'); - - // Swap -> Completed tracking - confirmedCb({ - id: 'swap1', - type: TransactionType.swap, - chainId: '0x1', - }); - - // Bridge -> startPolling - confirmedCb({ - id: 'bridgeConfirmed1', - type: TransactionType.bridge, - chainId: '0x1', - }); - - controller.stopAllPolling(); - - expect(startPollingSpy).toHaveBeenCalledWith({ - bridgeTxMetaId: 'bridgeConfirmed1', - }); - }); - it('restartPollingForFailedAttempts: throws when identifier missing, and when no match found', async () => { const { controller } = setup(); @@ -1024,44 +858,6 @@ describe('BridgeStatusController (target uncovered branches)', () => { jest.clearAllMocks(); }); - it.skip('transactionStatusUpdated (failed): returns early for intent txs (swapMetaData.isIntentTx)', () => { - const mockTxHistory = { - tx1: { - txMetaId: 'tx1', - originalTransactionId: 'tx1', - quote: minimalIntentQuoteResponse().quote, - account: '0xAccount1', - status: { - status: StatusTypes.PENDING, - srcChain: { chainId: 1, txHash: '0x' }, - }, - }, - }; - const { controller, messenger } = setup({ - mockTxHistory, - }); - - const failedCb = messenger.subscribe.mock.calls.find( - ([evt]: [any]) => - evt === 'TransactionController:transactionStatusUpdated', - )?.[1]; - - failedCb({ - transactionMeta: { - id: 'tx1', - chainId: '0x1', - type: TransactionType.bridge, - status: TransactionStatus.failed, - swapMetaData: { isIntentTx: true }, // <- triggers early return - }, - }); - - expect(controller.state.txHistory.tx1.status.status).toBe( - StatusTypes.FAILED, - ); - controller.stopAllPolling(); - }); - it('constructor restartPolling: skips items when shouldSkipFetchDueToFetchFailures returns true', () => { const accountAddress = '0xAccount1'; const { messenger } = createMessengerHarness(accountAddress); From df71533c8c21249051cbee92cfe773bcd31fb95a Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 20 Apr 2026 15:49:25 -0700 Subject: [PATCH 14/19] chore: handle non-evm history items --- .../bridge-status-controller.test.ts.snap | 2 + .../src/bridge-status-controller.test.ts | 37 ++++++++++--------- .../src/bridge-status-controller.ts | 11 +++--- .../src/utils/history.ts | 2 +- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index afb49068cc4..a5433c46bf2 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -164,6 +164,8 @@ exports[`BridgeStatusController constructor when history has no txHash, no txMet exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider throws error and is older than 2 days 1`] = `[]`; +exports[`BridgeStatusController constructor when history has solana srcChainId, no tx hash, no txMeta and is older than 2 days 1`] = `[]`; + exports[`BridgeStatusController constructor when history has tx hash, provider returns no receipt and is older than 2 days 1`] = ` [ { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 961631beb81..152da1d0ed9 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -922,6 +922,15 @@ describe('BridgeStatusController constructor', () => { id: 'undefined', }, }, + { + title: 'solana srcChainId, no tx hash, no txMeta', + txMeta: { + id: 'solanaTxMetaId1', + }, + srcChainId: ChainId.SOLANA, + expectedHistoryTxMetaId: 'solanaTxMetaId1', + txHash: 'solanaTxMetaId1', + }, { title: 'no txHash, no txMeta, provider returns no receipt', txMeta: { @@ -951,6 +960,7 @@ describe('BridgeStatusController constructor', () => { providerAction = () => Promise.resolve(), txMeta, expectedHistoryTxMetaId, + srcChainId, }) => { // Setup jest.useFakeTimers(); @@ -971,6 +981,7 @@ describe('BridgeStatusController constructor', () => { MockTxHistory.getPending({ txMetaId: txMeta?.id ?? 'unknownTxMetaId1', srcTxHash: txHash, + srcChainId, }), )[0]; @@ -1019,24 +1030,14 @@ describe('BridgeStatusController constructor', () => { await flushPromises(); // Assertions - const fetchTxStatusCalls = fetchBridgeTxStatusSpy.mock.calls.length; - const consoleWarnCalls = consoleWarnSpy.mock.calls; - const expectedMetric = messengerCallSpy.mock.calls; - - expect({ - expectedMetric, - fetchTxStatusCalls, - historyTxMetaIdAfterFetch: - controller.state.txHistory[historyKey]?.txMetaId, - consoleWarnCalls: consoleWarnCalls.map((call) => - JSON.stringify(call), - ), - }).toStrictEqual({ - expectedMetric: [], - fetchTxStatusCalls: 0, - historyTxMetaIdAfterFetch: expectedHistoryTxMetaId, - consoleWarnCalls: ['["Failed to fetch bridge tx status",{}]'], - }); + expect(messengerCallSpy.mock.calls).toStrictEqual([]); + expect(fetchBridgeTxStatusSpy.mock.calls).toHaveLength(0); + expect(controller.state.txHistory[historyKey]?.txMetaId).toBe( + expectedHistoryTxMetaId, + ); + expect( + consoleWarnSpy.mock.calls.map((call) => JSON.stringify(call)), + ).toStrictEqual(['["Failed to fetch bridge tx status",{}]']); expect(controller.state.txHistory[historyKey]?.status.status).toBe( expectedHistoryTxMetaId ? StatusTypes.PENDING : undefined, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 5a474d9f440..de35290dc2e 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -719,10 +719,7 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Mon, 20 Apr 2026 16:18:19 -0700 Subject: [PATCH 15/19] chore: update changelog --- packages/bridge-controller/CHANGELOG.md | 2 + .../bridge-status-controller/CHANGELOG.md | 10 ++ .../src/bridge-status-controller.ts | 132 +++++++++--------- 3 files changed, 78 insertions(+), 66 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index cf4e7a990f6..f8e69a872ea 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `AccountHardwareType` type and `getAccountHardwareType` function to the package exports ([#8503](https://github.com/MetaMask/core/pull/8503)) - `AccountHardwareType` is a union of `'Ledger' | 'Trezor' | 'QR Hardware' | 'Lattice' | null` - `getAccountHardwareType` maps a keyring type string to the corresponding `AccountHardwareType` value +- Read 'maxPendingHistoryItemAgeMs' feature flag from LaunchDarkly, which indicates when a history item can be treated as a failure ([#8479](https://github.com/MetaMask/core/pull/8479)) +- Add the `stale_transaction_hash` polling reason to indicate that a history item was removed from state do to having an invalid hash ([#8479](https://github.com/MetaMask/core/pull/8479)) ### Changed diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 4ad04f92c73..b79e67df4cb 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Remove stale bridge transactions from `txHistory` to prevent excessive polling. Once a history item exceeds the configured maximum age, polling backs off exponentially and also checks for the src tx hash's receipt. If there is no receipt, the history item's hash is presumed to be invalid and is deleted from state. ([#8479](https://github.com/MetaMask/core/pull/8479)) - Add missing action types for public `BridgeStatusController` methods ([#8367](https://github.com/MetaMask/core/pull/8367)) - The following types are now available: - `BridgeStatusControllerSubmitTxAction` @@ -17,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Replace `transactionFailed` and `transactionConfirmed` event subscriptions with `TransactionController:transactionStatusUpdated` ([#8479](https://github.com/MetaMask/core/pull/8479)) +- **BREAKING:** Add `RemoteFeatureFlags:getState` to allowed actions to retrieve max history item age config ([#8479](https://github.com/MetaMask/core/pull/8479)) - Add `account_hardware_type` field to all cross-chain swap analytics events ([#8503](https://github.com/MetaMask/core/pull/8503)) - `account_hardware_type` carries the specific hardware wallet brand (e.g. `'Ledger'`, `'QR Hardware'`) or `null` for software wallets - `is_hardware_wallet` is now derived from `account_hardware_type !== null`, keeping both fields in sync @@ -28,6 +31,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) - Bump `@metamask/bridge-controller` from `^70.0.1` to `^70.1.1` ([#8466](https://github.com/MetaMask/core/pull/8466), [#8474](https://github.com/MetaMask/core/pull/8474)) +### Fixed + +- Prevent invalid src hashes from being persisted in `txHistory` ([#8479](https://github.com/MetaMask/core/pull/8479)) + - Make transaction status subscribers generic so that `txHistory` items get updated if there are any transaction updates matching by actionId, txMetaId, hash or type + - Skip saving smart transaction hashes on transaction submission. This used to make it possible for invalid src hashes to be stored in state and polled indefinitely. Instead, the txHistory item will now be updated with the confirmed tx hash when the `transactionStatusUpdated` event is published + - If there is no srcTxHash in state, attempt to set it based on the local TransactionController state + ## [70.0.5] ### Changed diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index de35290dc2e..b3213d013cf 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -290,6 +290,72 @@ export class BridgeStatusController extends StaticIntervalPollingController { + this.#updateHistoryItem({ + historyKey, + status: StatusTypes.FAILED, + txHash: + txMeta.type && + [TransactionType.bridge, TransactionType.swap].includes(txMeta.type) + ? txMeta.hash + : undefined, + }); + + if (txMeta.status === TransactionStatus.rejected) { + return; + } + + // Skip account lookup and tracking when featureId is set (e.g. PERPS) + if (historyKey && this.state.txHistory[historyKey]?.featureId) { + return; + } + + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Failed, + historyKey, + getEVMTxPropertiesFromTransactionMeta(txMeta), + ); + }; + + // Only EVM txs + readonly #onTransactionConfirmed = ({ + txMeta, + historyKey, + }: { + txMeta: TransactionMeta; + historyKey?: string; + }): void => { + this.#updateHistoryItem({ + historyKey, + txHash: txMeta.hash, + }); + console.log('======TransactionController:transactionConfirmed', txMeta); + + switch (txMeta.type) { + case TransactionType.swap: + this.#updateHistoryItem({ + historyKey, + status: StatusTypes.COMPLETE, + }); + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Completed, + historyKey, + ); + break; + default: + if (historyKey) { + this.#startPollingForTxId(historyKey); + } + break; + } + }; + resetState = (): void => { this.update((state) => { state.txHistory = DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE.txHistory; @@ -547,72 +613,6 @@ export class BridgeStatusController extends StaticIntervalPollingController { - this.#updateHistoryItem({ - historyKey, - status: StatusTypes.FAILED, - txHash: - txMeta.type && - [TransactionType.bridge, TransactionType.swap].includes(txMeta.type) - ? txMeta.hash - : undefined, - }); - - if (txMeta.status === TransactionStatus.rejected) { - return; - } - - // Skip account lookup and tracking when featureId is set (e.g. PERPS) - if (historyKey && this.state.txHistory[historyKey]?.featureId) { - return; - } - - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Failed, - historyKey, - getEVMTxPropertiesFromTransactionMeta(txMeta), - ); - }; - - // Only EVM txs - readonly #onTransactionConfirmed = ({ - txMeta, - historyKey, - }: { - txMeta: TransactionMeta; - historyKey?: string; - }): void => { - this.#updateHistoryItem({ - historyKey, - txHash: txMeta.hash, - }); - console.log('======TransactionController:transactionConfirmed', txMeta); - - switch (txMeta.type) { - case TransactionType.swap: - this.#updateHistoryItem({ - historyKey, - status: StatusTypes.COMPLETE, - }); - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Completed, - historyKey, - ); - break; - default: - if (historyKey) { - this.#startPollingForTxId(historyKey); - } - break; - } - }; - /** * Handles the failure to fetch the bridge tx status or recognize the transaction hash * We eventually stop polling for the tx if we fail too many times or if the transaction is stale From 34336d2ecf65f33a584ac0f4d30d310d1cf282c1 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 20 Apr 2026 17:13:49 -0700 Subject: [PATCH 16/19] chore: rm console logs --- .../src/bridge-status-controller.test.ts | 11 ----------- .../src/bridge-status-controller.ts | 14 -------------- 2 files changed, 25 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 152da1d0ed9..6cdd1626b32 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -5733,17 +5733,6 @@ describe('BridgeStatusController', () => { ); expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` [ - [ - "======TransactionController:transactionStatusUpdated", - { - "actionId": undefined, - "batchId": undefined, - "hash": undefined, - "id": "perpsBridgeTxMetaId1", - "status": "confirmed", - "type": "bridge", - }, - ], [ "Error getting JWT token for bridge-api request", [Error: AuthenticationController:getBearerToken not implemented], diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index b3213d013cf..ec56a5cea15 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -219,20 +219,9 @@ export class BridgeStatusController extends StaticIntervalPollingController { if (!txMeta) { - console.error( - '======TransactionController:transactionStatusUpdated NOT FOUND', - ); return; } const { type, hash, status, id, actionId, batchId } = txMeta; - console.error('======TransactionController:transactionStatusUpdated', { - type, - hash, - status, - id, - actionId, - batchId, - }); // Allow event publishing if the txMeta is a swap/bridge OR if the // corresponding history item exists @@ -253,7 +242,6 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Tue, 21 Apr 2026 10:06:09 -0700 Subject: [PATCH 17/19] fix: track failure event once when approval fails --- .../bridge-status-controller.test.ts.snap | 83 ++++++++++--------- .../src/bridge-status-controller.test.ts | 18 +++- .../src/bridge-status-controller.ts | 11 +++ 3 files changed, 74 insertions(+), 38 deletions(-) diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index a5433c46bf2..f6a76240968 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -6366,43 +6366,52 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for bridge transaction if approval is dropped 1`] = ` [ - "BridgeController:trackUnifiedSwapBridgeEvent", - "Unified SwapBridge Failed", - { - "account_hardware_type": null, - "action_type": "swapbridge-v1", - "actual_time_minutes": 0, - "allowance_reset_transaction": undefined, - "approval_transaction": "COMPLETE", - "chain_id_destination": "eip155:10", - "chain_id_source": "eip155:42161", - "custom_slippage": true, - "destination_transaction": "FAILED", - "error_message": "Transaction dropped. tx-error", - "gas_included": false, - "gas_included_7702": false, - "is_hardware_wallet": false, - "location": "Main View", - "price_impact": 0, - "provider": "lifi_across", - "quote_vs_execution_ratio": 0, - "quoted_time_minutes": 0.25, - "quoted_vs_used_gas_ratio": 0, - "security_warnings": [], - "slippage_limit": 0, - "source_transaction": "COMPLETE", - "stx_enabled": false, - "swap_type": "crosschain", - "token_address_destination": "eip155:10/slip44:60", - "token_address_source": "eip155:42161/slip44:60", - "token_symbol_destination": "ETH", - "token_symbol_source": "ETH", - "usd_actual_gas": 0, - "usd_actual_return": 0, - "usd_amount_source": 0, - "usd_quoted_gas": 2.5778, - "usd_quoted_return": 0, - }, + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": "COMPLETE", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "FAILED", + "error_message": "Transaction dropped. tx-error", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Main View", + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 0.25, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], ] `; diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 6cdd1626b32..439d6f7fcaf 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -5063,7 +5063,23 @@ describe('BridgeStatusController', () => { }, ); - expect(messengerCallSpy.mock.lastCall).toMatchSnapshot(); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.dropped, + id: 'bridgeTxMetaId1WithApproval', + }, + }, + ); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); expect( bridgeStatusController.state.txHistory.bridgeTxMetaId1WithApproval .status.status, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index ec56a5cea15..a384a7b49d7 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -285,6 +285,11 @@ export class BridgeStatusController extends StaticIntervalPollingController { + // Check if the history item is already marked as a failure + const isHistoryItemAlreadyFailed = historyKey + ? this.state.txHistory[historyKey]?.status.status === StatusTypes.FAILED + : false; + this.#updateHistoryItem({ historyKey, status: StatusTypes.FAILED, @@ -304,6 +309,12 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Tue, 21 Apr 2026 10:09:22 -0700 Subject: [PATCH 18/19] chore: rename enum --- packages/bridge-controller/CHANGELOG.md | 2 +- packages/bridge-controller/src/utils/metrics/constants.ts | 2 +- .../bridge-status-controller/src/bridge-status-controller.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index f8e69a872ea..7528658d2c0 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AccountHardwareType` is a union of `'Ledger' | 'Trezor' | 'QR Hardware' | 'Lattice' | null` - `getAccountHardwareType` maps a keyring type string to the corresponding `AccountHardwareType` value - Read 'maxPendingHistoryItemAgeMs' feature flag from LaunchDarkly, which indicates when a history item can be treated as a failure ([#8479](https://github.com/MetaMask/core/pull/8479)) -- Add the `stale_transaction_hash` polling reason to indicate that a history item was removed from state do to having an invalid hash ([#8479](https://github.com/MetaMask/core/pull/8479)) +- Add the `invalid_transaction_hash` polling reason to indicate that a history item was removed from state do to having an invalid hash ([#8479](https://github.com/MetaMask/core/pull/8479)) ### Changed diff --git a/packages/bridge-controller/src/utils/metrics/constants.ts b/packages/bridge-controller/src/utils/metrics/constants.ts index 9e6c7265084..85de258c851 100644 --- a/packages/bridge-controller/src/utils/metrics/constants.ts +++ b/packages/bridge-controller/src/utils/metrics/constants.ts @@ -27,7 +27,7 @@ export enum UnifiedSwapBridgeEventName { export enum PollingStatus { MaxPollingReached = 'max_polling_reached', - StaleTransactionHash = 'stale_transaction_hash', + InvalidTransactionHash = 'invalid_transaction_hash', ManuallyRestarted = 'manually_restarted', } diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index a384a7b49d7..e339d22ba98 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -662,7 +662,7 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Tue, 21 Apr 2026 10:11:16 -0700 Subject: [PATCH 19/19] fix: lint errors --- .../src/bridge-status-controller.test.ts | 4 ++-- packages/bridge-status-controller/src/types.ts | 2 +- packages/bridge-status-controller/src/utils/bridge-status.ts | 2 +- packages/bridge-status-controller/src/utils/feature-flags.ts | 3 ++- packages/bridge-status-controller/src/utils/metrics.ts | 2 +- packages/bridge-status-controller/src/utils/network.ts | 1 + 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 439d6f7fcaf..73153dd1cb5 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -24,12 +24,12 @@ import type { MessengerEvents, MockAnyNamespace, } from '@metamask/messenger'; +import type { Provider } from '@metamask/network-controller'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { TransactionType, TransactionStatus, } from '@metamask/transaction-controller'; -import type { Provider } from '@metamask/network-controller'; import type { TransactionMeta, TransactionParams, @@ -55,8 +55,8 @@ import type { StatusResponse, } from './types'; import * as bridgeStatusUtils from './utils/bridge-status'; -import * as transactionUtils from './utils/transaction'; import * as historyUtils from './utils/history'; +import * as transactionUtils from './utils/transaction'; type AllBridgeStatusControllerActions = MessengerActions; diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 5260dd6e4b3..9f4d1195fbf 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -16,13 +16,13 @@ import type { import type { GetGasFeeState } from '@metamask/gas-fee-controller'; import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; -import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerGetNetworkClientByIdAction, NetworkControllerGetStateAction, } from '@metamask/network-controller'; import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; import type { Infer } from '@metamask/superstruct'; import type { diff --git a/packages/bridge-status-controller/src/utils/bridge-status.ts b/packages/bridge-status-controller/src/utils/bridge-status.ts index 16cce768cb2..0f4677057c3 100644 --- a/packages/bridge-status-controller/src/utils/bridge-status.ts +++ b/packages/bridge-status-controller/src/utils/bridge-status.ts @@ -12,8 +12,8 @@ import type { StatusRequest, BridgeStatusControllerMessenger, } from '../types'; -import { validateBridgeStatusResponse } from './validators'; import { getNetworkClientByChainId } from './network'; +import { validateBridgeStatusResponse } from './validators'; export const getBridgeStatusUrl = (bridgeApiBaseUrl: string): string => `${bridgeApiBaseUrl}/getTxStatus`; diff --git a/packages/bridge-status-controller/src/utils/feature-flags.ts b/packages/bridge-status-controller/src/utils/feature-flags.ts index cb96bd8e8d1..cd537065347 100644 --- a/packages/bridge-status-controller/src/utils/feature-flags.ts +++ b/packages/bridge-status-controller/src/utils/feature-flags.ts @@ -1,6 +1,7 @@ import { getBridgeFeatureFlags } from '@metamask/bridge-controller'; -import { BridgeStatusControllerMessenger } from '../types'; + import { DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS } from '../constants'; +import { BridgeStatusControllerMessenger } from '../types'; export const getMaxPendingHistoryItemAgeMs = ( messenger: BridgeStatusControllerMessenger, diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 3372265f5bf..8f53fc5548b 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -39,12 +39,12 @@ import type { BridgeHistoryItem, BridgeStatusControllerMessenger, } from '../types'; +import { getAccountByAddress } from './accounts'; import { calcActualGasUsed } from './gas'; import { getActualBridgeReceivedAmount, getActualSwapReceivedAmount, } from './swap-received-amount'; -import { getAccountByAddress } from './accounts'; export const getTxStatusesFromHistory = ({ status, diff --git a/packages/bridge-status-controller/src/utils/network.ts b/packages/bridge-status-controller/src/utils/network.ts index 4e89cb22c3a..98bc47f9e1e 100644 --- a/packages/bridge-status-controller/src/utils/network.ts +++ b/packages/bridge-status-controller/src/utils/network.ts @@ -2,6 +2,7 @@ import { formatChainIdToHex } from '@metamask/bridge-controller'; import type { GenericQuoteRequest } from '@metamask/bridge-controller'; import type { NetworkClient } from '@metamask/network-controller'; + import type { BridgeStatusControllerMessenger } from '../types'; export const getSelectedChainId = (