From 2ee6619d0a37301a44151e1fed0991a6a94e9f38 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Wed, 18 Mar 2026 17:31:40 -0700 Subject: [PATCH 01/16] feat: implement new submission flows test: ignore types.ts --- .../bridge-status-controller/jest.config.js | 7 +- .../src/bridge-status-controller.ts | 2 + .../src/strategy/batch-strategy.ts | 65 +++++++ .../src/strategy/evm-strategy.ts | 157 +++++++++++++++ .../src/strategy/index.ts | 60 ++++++ .../src/strategy/intent-strategy.ts | 181 ++++++++++++++++++ .../src/strategy/non-evm-strategy.ts | 115 +++++++++++ .../src/strategy/types.ts | 86 +++++++++ 8 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 packages/bridge-status-controller/src/strategy/batch-strategy.ts create mode 100644 packages/bridge-status-controller/src/strategy/evm-strategy.ts create mode 100644 packages/bridge-status-controller/src/strategy/index.ts create mode 100644 packages/bridge-status-controller/src/strategy/intent-strategy.ts create mode 100644 packages/bridge-status-controller/src/strategy/non-evm-strategy.ts create mode 100644 packages/bridge-status-controller/src/strategy/types.ts diff --git a/packages/bridge-status-controller/jest.config.js b/packages/bridge-status-controller/jest.config.js index b1e1437631..121fa72f48 100644 --- a/packages/bridge-status-controller/jest.config.js +++ b/packages/bridge-status-controller/jest.config.js @@ -16,7 +16,12 @@ module.exports = merge(baseConfig, { coverageProvider: 'v8', - coveragePathIgnorePatterns: ['.*/index\\.ts', '.*-method-action-types\\.ts'], + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + '.*/strategy/types\\.ts$', + '.*/index\\.ts', + '.*-method-action-types\\.ts', + ], // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 208cfe7f98..201914894a 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -934,6 +934,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata; diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts new file mode 100644 index 0000000000..29db022c6b --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -0,0 +1,65 @@ +import { isEvmTxData } from '@metamask/bridge-controller'; + +import type { SubmitStrategyParams, SubmitStepResult } from './types'; +import { + addTransactionBatch, + getAddTransactionBatchParams, +} from '../utils/transaction'; + +/** + * Submits batched EVM transactions to the TransactionController + * + * @param args - The parameters for the transaction + * @yields The approvalMeta and tradeMeta for the batched transaction + */ +export async function* submitBatchHandler( + args: SubmitStrategyParams, +): AsyncGenerator { + const { + requireApproval, + quoteResponse, + messenger, + isBridgeTx, + addTransactionBatchFn, + } = args; + if (!isEvmTxData(quoteResponse.trade)) { + throw new Error( + 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', + ); + } + const transactionParams = await getAddTransactionBatchParams({ + messenger, + isBridgeTx, + resetApproval: quoteResponse.resetApproval, + approval: + quoteResponse.approval && isEvmTxData(quoteResponse.approval) + ? quoteResponse.approval + : undefined, + trade: quoteResponse.trade, + quoteResponse, + requireApproval, + }); + + const { approvalMeta, tradeMeta } = await addTransactionBatch( + messenger, + addTransactionBatchFn, + transactionParams, + ); + + yield { + type: 'setTradeMeta', + payload: tradeMeta, + }; + + yield { + type: 'addHistoryItem', + payload: { + approvalTxId: approvalMeta?.id, + bridgeTxMeta: { + id: tradeMeta.id, + hash: tradeMeta.hash, + batchId: tradeMeta.batchId, + }, + }, + }; +} diff --git a/packages/bridge-status-controller/src/strategy/evm-strategy.ts b/packages/bridge-status-controller/src/strategy/evm-strategy.ts new file mode 100644 index 0000000000..232ce4c4b7 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/evm-strategy.ts @@ -0,0 +1,157 @@ +/* eslint-disable consistent-return */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { isEvmTxData } from '@metamask/bridge-controller'; +import type { TxData } from '@metamask/bridge-controller'; +import { TransactionType } from '@metamask/transaction-controller'; + +import type { SubmitStrategyParams, SubmitStepResult } from './types'; +import { getApprovalTraceParams } from '../utils/trace'; +import { + generateActionId, + handleApprovalDelay, + handleMobileHardwareWalletDelay, + submitEvmTransaction, +} from '../utils/transaction'; + +/** + * Submits a single trade and returns the txMetaId + * + * @param args - The parameters for the transaction + * @param args.messenger - The messenger + * @param args.requireApproval - Whether to require approval for the transaction + * @param transactionType - The type of transaction to submit + * @param trade - The tx to submit + * @param submitParams - Optional parameters to pass to the submitEvmTransaction function + * @returns The txMeta of the transaction + */ +const handleSingleTx = async ( + { messenger, requireApproval }: SubmitStrategyParams, + transactionType: TransactionType, + trade: TxData, + submitParams: Partial[0]> = {}, +) => { + const approvalTxMeta = await submitEvmTransaction({ + messenger, + trade, + transactionType, + requireApproval, + ...submitParams, + }); + + return approvalTxMeta; +}; + +/** + * Submits the approval and resetApproval transactions through the TransactionController. + * If there is a resetApproval, it will be submitted first. + * But only the approval's txMetaId will be returned. + * + * @param args - The parameters for the submission flow + * + * @returns The approvalTxId of the approval transaction + */ +export const handleEvmApprovals = async (args: SubmitStrategyParams) => { + const { quoteResponse, isBridgeTx } = args; + const { approval, resetApproval } = quoteResponse; + if (!approval || !isEvmTxData(approval)) { + return undefined; + } + + const transactionType = isBridgeTx + ? TransactionType.bridgeApproval + : TransactionType.swapApproval; + + if (resetApproval) { + await handleSingleTx(args, transactionType, resetApproval); + } + + if (approval) { + const approvalTxMeta = await handleSingleTx( + args, + transactionType, + approval, + ); + return approvalTxMeta?.id; + } +}; + +/** + * Sequentially submits EVM resetApproval, approval and trade transactions through the TransactionController. + * + * @param args - The parameters for the transaction + * @yields Data for updating the BridgeStatusController + */ +export async function* submitEvmHandler( + args: SubmitStrategyParams, +): AsyncGenerator { + const { + quoteResponse, + traceFn, + requireApproval, + isStxEnabledOnClient, + isBridgeTx, + } = args; + if (!isEvmTxData(quoteResponse.trade)) { + throw new Error( + 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', + ); + } + + // Submit resetApproval and approval transactions if present + const approvalTxId = await traceFn( + getApprovalTraceParams(quoteResponse, isStxEnabledOnClient), + async () => { + return await handleEvmApprovals(args); + }, + ); + // Delay after approval + if (approvalTxId) { + await handleApprovalDelay(quoteResponse.quote.srcChainId); + await handleMobileHardwareWalletDelay(requireApproval); + } + + // Generate trade actionId for pre-submission history + const actionId = generateActionId(); + + // Add pre-submission history keyed by actionId + // This ensures we have quote data available if transaction fails during submission + yield { + type: 'addHistoryItem', + payload: { + approvalTxId, + actionId, + }, + }; + + const transactionType = isBridgeTx + ? TransactionType.bridge + : TransactionType.swap; + const tradeMeta = await handleSingleTx( + args, + transactionType, + quoteResponse.trade, + { + // TODO figure out if this is needed + // Pass txFee when gasIncluded is true to use the quote's gas fees + // instead of re-estimating (which would fail for max native token swaps) + txFee: quoteResponse.quote.gasIncluded + ? quoteResponse.quote.feeData.txFee + : undefined, + actionId, + }, + ); + + // Use the tradeMeta's id as history key + yield { + type: 'rekeyHistoryItem', + payload: { + actionId, + tradeMeta, + }, + }; + + yield { + type: 'setTradeMeta', + payload: tradeMeta, + }; +} diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts new file mode 100644 index 0000000000..c6176a4fff --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/index.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { isNonEvmChainId } from '@metamask/bridge-controller'; + +import { submitBatchHandler } from './batch-strategy'; +import { submitEvmHandler as defaultSubmitHandler } from './evm-strategy'; +import { submitIntentHandler } from './intent-strategy'; +import { submitNonEvmHandler } from './non-evm-strategy'; +import type { + SubmitStrategyParams, + SubmitStepResult, + SubmitStrategy, +} from './types'; + +const SUBMIT_STRATEGY_REGISTRY: SubmitStrategy[] = [ + { + matchesFlow: (params: SubmitStrategyParams) => { + const { quoteResponse } = params; + return isNonEvmChainId(quoteResponse.quote.srcChainId); + }, + execute: submitNonEvmHandler, + }, + { + matchesFlow: (params: SubmitStrategyParams) => { + const { quoteResponse, isStxEnabledOnClient, isDelegatedAccount } = + params; + return ( + isStxEnabledOnClient || + quoteResponse.quote.gasIncluded7702 || + isDelegatedAccount + ); + }, + execute: submitBatchHandler, + }, + { + matchesFlow: (params: SubmitStrategyParams) => { + const { quoteResponse } = params; + return Boolean(quoteResponse.quote.intent); + }, + execute: submitIntentHandler, + }, +]; + +/** + * Selects the appropriate submit strategy based on the quote parameters and executes it + * + * @param params - The parameters for the transaction + * @returns An async generator that yields results from each step of the submit flow. The yielded + * results are used to update the BridgeStatusController state and emit events. + */ +const executeSubmitFlow = ( + params: SubmitStrategyParams, +): AsyncGenerator => { + return ( + SUBMIT_STRATEGY_REGISTRY.find((strategy) => strategy.matchesFlow(params)) + ?.execute ?? defaultSubmitHandler + )(params); +}; + +export default executeSubmitFlow; diff --git a/packages/bridge-status-controller/src/strategy/intent-strategy.ts b/packages/bridge-status-controller/src/strategy/intent-strategy.ts new file mode 100644 index 0000000000..62d03cde10 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/intent-strategy.ts @@ -0,0 +1,181 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { formatChainIdToHex, isEvmTxData } from '@metamask/bridge-controller'; +import { TransactionType } from '@metamask/transaction-controller'; + +import { handleEvmApprovals } from './evm-strategy'; +import { SubmitStrategyParams, SubmitStepResult } from './types'; +import { getJwt } from '../utils/authentication'; +import { + getIntentFromQuote, + mapIntentOrderStatusToTransactionStatus, + postSubmitOrder, +} from '../utils/intent-api'; +import { signTypedMessage } from '../utils/keyring'; +import { getNetworkClientIdByChainId } from '../utils/network'; +import { + addSyntheticTransaction, + waitForTxConfirmation, +} from '../utils/transaction'; + +/** + * Submits a synthetic EVM transaction to the TransactionController in order to display the intent order's + * status in theclients, before the actual transaction is finalized on chain. The resulting transaction + * is only available locally and is not submitted to the chain. + * + * @param orderUid - The order uid of the intent transaction + * @param args - The parameters for the transaction + * @returns The tradeMeta for the synthetic transaction + */ +const handleSyntheticTx = async ( + orderUid: string, + args: SubmitStrategyParams, +) => { + const { quoteResponse, messenger, isBridgeTx, selectedAccount } = args; + const { + quote: { srcChainId }, + } = quoteResponse; + + // Determine transaction type: swap for same-chain, bridge for cross-chain + const transactionType = isBridgeTx + ? /* c8 ignore start */ + TransactionType.bridge + : /* c8 ignore end */ + TransactionType.swap; + + const networkClientId = getNetworkClientIdByChainId(messenger, srcChainId); + + // This is a synthetic transaction whose purpose is to be able + // to track the order status via the history + if (!isEvmTxData(quoteResponse.trade)) { + throw new Error('Failed to submit intent: trade is not an EVM transaction'); + } + const intent = getIntentFromQuote(quoteResponse); + // This is a synthetic transaction whose purpose is to be able + // to track the order status via the history + /** + * @deprecated use trade data from quote response instead + */ + const intentTransactionParams = { + chainId: formatChainIdToHex(srcChainId), + from: selectedAccount.address, + to: + intent.settlementContract ?? '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', // Default settlement contract + data: `0x${orderUid?.slice(-8)}`, // Use last 8 chars of orderUid to make each transaction unique + value: '0x0', + gas: '0x5208', // Minimal gas for display purposes + gasPrice: '0x3b9aca00', // 1 Gwei - will be converted to EIP-1559 fees if network supports it + }; + + const initialTxMeta = await addSyntheticTransaction( + messenger, + intentTransactionParams, + { + requireApproval: false, + networkClientId, + type: transactionType, + }, + ); + return initialTxMeta; +}; + +/** + * Submits batched EVM transactions to the TransactionController + * + * @param args - The parameters for the transaction + * @param args.quoteResponse - The quote response + * @param args.messenger - The messenger + * @param args.selectedAccount - The selected account + * @param args.traceFn - The trace function + * @param args.isBridgeTx - Whether the transaction is a bridge transaction + * @returns The approvalTxId and tradeMeta for the non-EVM transaction + */ +const handleSubmitIntent = async (args: SubmitStrategyParams) => { + const { + quoteResponse, + messenger, + selectedAccount, + clientId, + fetchFn, + bridgeApiBaseUrl, + } = args; + const { srcChainId, requestId } = quoteResponse.quote; + + const intent = getIntentFromQuote(quoteResponse); + const signature = await signTypedMessage({ + messenger, + accountAddress: selectedAccount.address, + typedData: intent.typedData, + }); + + const { id: orderUid, status } = await postSubmitOrder({ + params: { + srcChainId, + quoteId: requestId, + signature, + order: intent.order, + userAddress: selectedAccount.address, + aggregatorId: intent.protocol, + }, + clientId, + jwt: await getJwt(messenger), + fetchFn, + bridgeApiBaseUrl, + }); + + return { + orderUid, + orderStatus: status, + }; +}; + +export async function* submitIntentHandler( + args: SubmitStrategyParams, +): AsyncGenerator { + // TODO handle STX/batch approvals + const approvalTxId = await handleEvmApprovals(args); + approvalTxId && (await waitForTxConfirmation(args.messenger, approvalTxId)); + + // TODO add to history after approval tx is confirmed + + // Submit the intent order to the bridge-api + const { orderUid, orderStatus } = await handleSubmitIntent(args); + + // Initialize a transaction in the TransactionController + const syntheticTxMeta = await handleSyntheticTx(orderUid, { + ...args, + requireApproval: false, + isStxEnabledOnClient: false, + }); + + // Use synthetic transaction metadata + translated intent order status as the tradeMeta + if (syntheticTxMeta && orderStatus) { + yield { + type: 'setTradeMeta', + payload: { + ...syntheticTxMeta, + // Map intent order status to TransactionController status + status: mapIntentOrderStatusToTransactionStatus(orderStatus), + }, + }; + + // Update txHistory with synthetic txMeta and order id + yield { + type: 'addHistoryItem', + payload: { + // Use orderId as the history key for intent transactions + bridgeTxMeta: { + id: orderUid, + }, + approvalTxId, + // Keep original txId for TransactionController updates + originalTransactionId: syntheticTxMeta?.id, + }, + }; + } + + // Start polling using the orderId as the history key + yield { + type: 'startPolling', + payload: orderUid, + }; +} diff --git a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts new file mode 100644 index 0000000000..41d93cfa73 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts @@ -0,0 +1,115 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { + isBitcoinTrade, + isTronChainId, + isTronTrade, +} from '@metamask/bridge-controller'; + +import type { SubmitStrategyParams, SubmitStepResult } from './types'; +import { handleNonEvmTx } from '../utils/snaps'; +import { getApprovalTraceParams } from '../utils/trace'; +import { handleApprovalDelay } from '../utils/transaction'; + +/** + * Submits the approval transaction for a non-EVM transaction if present + * + * @param args - The parameters for the transaction + * @returns The tx id of the approval transaction + */ +const handleTronApproval = async (args: SubmitStrategyParams) => { + const { quoteResponse, traceFn } = args; + + const approvalTxId = await traceFn( + getApprovalTraceParams(quoteResponse, false), + async () => { + if (quoteResponse.approval && isTronTrade(quoteResponse.approval)) { + const txMeta = await handleNonEvmTx( + args.messenger, + quoteResponse.approval, + quoteResponse, + args.selectedAccount, + ); + return txMeta.id; + } + return undefined; + }, + ); + + if (approvalTxId) { + // Add delay after approval similar to EVM flow + await handleApprovalDelay(quoteResponse.quote.srcChainId); + return approvalTxId; + } + return undefined; +}; + +/** + * Submits batched EVM transactions to the TransactionController + * + * @param args - The parameters for the transaction + * @param args.quoteResponse - The quote response + * @param args.messenger - The messenger + * @param args.selectedAccount - The selected account + * @param args.traceFn - The trace function + * @param args.isBridgeTx - Whether the transaction is a bridge transaction + * @yields The approvalTxId and tradeMeta for the non-EVM transaction + */ +export async function* submitNonEvmHandler( + args: SubmitStrategyParams, +): AsyncGenerator { + const { quoteResponse, isBridgeTx } = args; + yield { + type: 'publishFailedEvent', + payload: true, + }; + if ( + !( + isTronTrade(quoteResponse.trade) || + isBitcoinTrade(quoteResponse.trade) || + typeof quoteResponse.trade === 'string' + ) + ) { + throw new Error( + 'Failed to submit cross-chain swap transaction: trade is not a non-EVM transaction', + ); + } + + const approvalTxId = await handleTronApproval(args); + + // TODO bridge-status should update history with actionId if approvalTxId is present + + const tradeMeta = await handleNonEvmTx( + args.messenger, + quoteResponse.trade, + quoteResponse, + args.selectedAccount, + ); + + yield { + type: 'setTradeMeta', + payload: tradeMeta, + }; + + yield { + type: 'addHistoryItem', + payload: { + approvalTxId, + bridgeTxMeta: { + id: tradeMeta.id, + hash: tradeMeta.hash, + }, + }, + }; + + yield { + type: 'startPolling', + payload: tradeMeta.id, + }; + + if (!isTronChainId(quoteResponse.quote.srcChainId) && !isBridgeTx) { + yield { + type: 'publishCompletedEvent', + payload: tradeMeta.id, + }; + } +} diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts new file mode 100644 index 0000000000..5102a18bb8 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -0,0 +1,86 @@ +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import type { + BridgeClientId, + QuoteMetadata, + QuoteResponse, +} from '@metamask/bridge-controller'; +import type { TraceCallback } from '@metamask/controller-utils'; +import type { + TransactionController, + TransactionMeta, +} from '@metamask/transaction-controller'; + +import type { + BridgeStatusControllerMessenger, + FetchFunction, + StartPollingForBridgeTxStatusArgs, +} from '../types'; + +/** + * Any possible result returned by steps in a submission strategy. These can be returned in any order. + */ +export type SubmitStepResult = + | { + type: 'publishFailedEvent'; + payload: boolean; + } + | { + type: 'addHistoryItem'; + payload: Pick< + StartPollingForBridgeTxStatusArgs, + 'approvalTxId' | 'bridgeTxMeta' | 'originalTransactionId' | 'actionId' + >; + } + | { + type: 'rekeyHistoryItem'; + payload: { + /** The actionId of the preceeding `approval` transaction */ + actionId: string; + /** The {@link TransactionMeta} for the `trade` transaction after it has been submitted successfully */ + tradeMeta: TransactionMeta; + }; + } + | { + type: 'startPolling'; + /** The `txHistory` key of the transaction to start polling for */ + payload: string; + } + | { + type: 'publishCompletedEvent'; + /** The `txHistory` key of the transaction that has been submitted successfully */ + payload: string; + } + | { + type: 'setTradeMeta'; + /** The {@link TransactionMeta} for the transaction that has been submitted successfully */ + payload: TransactionMeta; + }; + +/** + * The parameters for the submission flow + */ +export type SubmitStrategyParams = { + addTransactionBatchFn: TransactionController['addTransactionBatch']; + isBridgeTx: boolean; + isDelegatedAccount: boolean; + isStxEnabledOnClient: boolean; + messenger: BridgeStatusControllerMessenger; + quoteResponse: QuoteResponse & QuoteMetadata; + requireApproval: boolean; + selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string]; + traceFn: TraceCallback; + // Used for intent transactions + fetchFn: FetchFunction; + clientId: BridgeClientId; + bridgeApiBaseUrl: string; +}; + +/** + * A strategy for submitting a transaction and/or intent + */ +export type SubmitStrategy = { + matchesFlow: (params: SubmitStrategyParams) => boolean; + execute: ( + params: SubmitStrategyParams, + ) => AsyncGenerator; +}; From f6ec4102f04da533c7e96d915357c098da1082c8 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Wed, 18 Mar 2026 17:32:03 -0700 Subject: [PATCH 02/16] feat: use submission flows in controller --- .../bridge-status-controller.intent.test.ts | 2 - .../src/bridge-status-controller.ts | 577 ++++-------------- .../src/strategy/evm-strategy.ts | 4 +- .../src/strategy/non-evm-strategy.ts | 4 - .../src/strategy/types.ts | 4 - 5 files changed, 111 insertions(+), 480 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 46dcad4c88..9a3b5dfd71 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 @@ -371,9 +371,7 @@ describe('BridgeStatusController (intent swaps)', () => { "chainId": "0x1", "hash": undefined, "id": "intentDisplayTxId1", - "isIntentTx": true, "networkClientId": "network-client-id-1", - "orderUid": "order-uid-approve-1", "status": "submitted", "time": 1773879217428, "txParams": { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 201914894a..2e7e498042 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -2,25 +2,19 @@ import type { StateMetadata } from '@metamask/base-controller'; import type { QuoteMetadata, RequiredEventContextFromClient, - TxData, QuoteResponse, Trade, - TronTradeData, } from '@metamask/bridge-controller'; import { - formatChainIdToHex, isNonEvmChainId, StatusTypes, UnifiedSwapBridgeEventName, isCrossChain, - isTronChainId, - isEvmTxData, isHardwareWallet, MetricsActionType, MetaMetricsSwapsEventSource, - isBitcoinTrade, - isTronTrade, PollingStatus, + formatChainIdToHex, } from '@metamask/bridge-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; @@ -41,6 +35,8 @@ import { MAX_ATTEMPTS, REFRESH_INTERVAL_MS, } from './constants'; +import executeSubmitFlow from './strategy'; +import type { SubmitStrategyParams } from './strategy/types'; import type { BridgeStatusControllerState, StartPollingForBridgeTxStatusArgsSerialized, @@ -63,13 +59,6 @@ import { rekeyHistoryItemInState, shouldPollHistoryItem, } from './utils/history'; -import { - getIntentFromQuote, - IntentSubmissionParams, - mapIntentOrderStatusToTransactionStatus, - postSubmitOrder, -} from './utils/intent-api'; -import { signTypedMessage } from './utils/keyring'; import { getFinalizedTxProperties, getPriceImpactFromQuote, @@ -80,23 +69,11 @@ import { getTxStatusesFromHistory, getPreConfirmationPropertiesFromQuote, } from './utils/metrics'; +import { getSelectedChainId } from './utils/network'; +import { getTraceParams } from './utils/trace'; import { - getNetworkClientIdByChainId, - getSelectedChainId, -} from './utils/network'; -import { handleNonEvmTx } from './utils/snaps'; -import { getApprovalTraceParams, getTraceParams } from './utils/trace'; -import { - getAddTransactionBatchParams, - handleApprovalDelay, - handleMobileHardwareWalletDelay, - generateActionId, - waitForTxConfirmation, getTransactionMetaById, - addTransactionBatch, - addSyntheticTransaction, getTransactions, - submitEvmTransaction, checkIsDelegatedAccount, } from './utils/transaction'; @@ -480,9 +457,9 @@ export class BridgeStatusController extends StaticIntervalPollingController ): void => { + // Use actionId as key for pre-submission, or txMeta.id for post-submission 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; }); }; @@ -849,80 +826,6 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, - isBridgeTx: boolean, - srcChainId: QuoteResponse['quote']['srcChainId'], - approval?: TxData | TronTradeData, - resetApproval?: TxData, - requireApproval?: boolean, - ): Promise => { - if (approval && isEvmTxData(approval)) { - const approveTx = async (): Promise => { - if (resetApproval) { - await submitEvmTransaction({ - messenger: this.messenger, - transactionType: TransactionType.bridgeApproval, - trade: resetApproval, - }); - } - - const approvalTxMeta = await submitEvmTransaction({ - messenger: this.messenger, - transactionType: isBridgeTx - ? TransactionType.bridgeApproval - : TransactionType.swapApproval, - trade: approval, - requireApproval, - }); - - await handleApprovalDelay(srcChainId); - return approvalTxMeta; - }; - - return await this.#trace( - getApprovalTraceParams(quoteResponse, false), - approveTx, - ); - } - - return undefined; - }; - - // TODO simplify and make more readable - /** - * Submits batched EVM transactions to the TransactionController - * - * @param args - The parameters for the transaction - * @param args.isBridgeTx - Whether the transaction is a bridge transaction - * @param args.trade - The trade data to confirm - * @param args.approval - The approval data to confirm - * @param args.resetApproval - The ethereum:USDT reset approval data to confirm - * @param args.quoteResponse - The quote response - * @param args.requireApproval - Whether to require approval for the transaction - * @returns The approvalMeta and tradeMeta for the batched transaction - */ - readonly #handleEvmTransactionBatch = async ( - args: Omit< - Parameters[0], - 'messenger' | 'estimateGasFeeFn' - >, - ): Promise<{ - approvalMeta?: TransactionMeta; - tradeMeta: TransactionMeta; - }> => { - const transactionParams = await getAddTransactionBatchParams({ - messenger: this.messenger, - ...args, - }); - - return await addTransactionBatch( - this.messenger, - this.#addTransactionBatchFn, - transactionParams, - ); - }; - /** * Submits a cross-chain swap transaction * @@ -957,7 +860,19 @@ export class BridgeStatusController extends StaticIntervalPollingController; - let approvalTxId: string | undefined; - let isDelegatedAccount = false; - const startTime = Date.now(); - - const isBridgeTx = isCrossChain( - quoteResponse.quote.srcChainId, - quoteResponse.quote.destChainId, - ); - const isTronTx = isTronChainId(quoteResponse.quote.srcChainId); + let tradeTxMeta: TransactionMeta; try { // Emit Submitted event after submit button is clicked @@ -987,171 +893,86 @@ export class BridgeStatusController extends StaticIntervalPollingController { - return quoteResponse.approval && - isTronTrade(quoteResponse.approval) - ? await handleNonEvmTx( - this.messenger, - quoteResponse.approval, - quoteResponse, - selectedAccount, - ) - : /* c8 ignore start */ - undefined; - /* c8 ignore end */ - }, - ); - - approvalTxId = approvalTxMeta?.id; - - // Add delay after approval similar to EVM flow - await handleApprovalDelay(quoteResponse.quote.srcChainId); - } - - txMeta = await this.#trace( - getTraceParams(quoteResponse, false), - async () => { - if ( - !( - isTronTrade(quoteResponse.trade) || - isBitcoinTrade(quoteResponse.trade) || - typeof quoteResponse.trade === 'string' - ) - ) { - throw new Error( - 'Failed to submit cross-chain swap transaction: trade is not a non-EVM transaction', + return await this.#trace( + getTraceParams(quoteResponse, isStxEnabledOnClient), + async () => { + /** + * Check if the account is an EIP-7702 delegated account. + * Delegated accounts only allow 1 in-flight tx, so approve + swap + * must be batched into a single transaction + */ + const isDelegatedAccount = isNonEvmChainId( + quoteResponse.quote.srcChainId, + ) + ? false + : await checkIsDelegatedAccount( + this.messenger, + selectedAccount.address as Hex, + [formatChainIdToHex(quoteResponse.quote.srcChainId)], ); + + const params: SubmitStrategyParams = { + quoteResponse, + isStxEnabledOnClient, + isDelegatedAccount, + messenger: this.messenger, + selectedAccount, + traceFn: this.#trace, + requireApproval, + isBridgeTx, + clientId: this.#clientId, + fetchFn: this.#fetchFn, + bridgeApiBaseUrl: this.#config.customBridgeApiBaseUrl, + addTransactionBatchFn: this.#addTransactionBatchFn, + }; + const steps = executeSubmitFlow(params); + + // Each submission strategy determines when to return values, which means these values can be returned in any order + for await (const { type, payload } of steps) { + if (type === 'rekeyHistoryItem') { + this.#rekeyHistoryItem(payload.actionId, payload.tradeMeta); } - return await handleNonEvmTx( - this.messenger, - quoteResponse.trade, - quoteResponse, - selectedAccount, - ); - }, - ); - } else { - // Submit EVM tx - // For hardware wallets on Mobile, this is fixes an issue where the Ledger does not get prompted for the 2nd approval - // Extension does not have this issue - const requireApproval = - this.#clientId === BridgeClientId.MOBILE && isHardwareAccount; - - // Handle smart transactions if enabled - txMeta = await this.#trace( - getTraceParams(quoteResponse, isStxEnabledOnClient), - async () => { - if (!isEvmTxData(quoteResponse.trade)) { - throw new Error( - 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', - ); + if (type === 'setTradeMeta') { + tradeTxMeta = payload; } - // Check if the account is an EIP-7702 delegated account - // Delegated accounts only allow 1 in-flight tx, so approve + swap - // must be batched into a single transaction - const hexChainId = formatChainIdToHex( - quoteResponse.quote.srcChainId, - ); - isDelegatedAccount = await checkIsDelegatedAccount( - this.messenger, - quoteResponse.trade.from as `0x`, - [hexChainId], - ); - if ( - isStxEnabledOnClient || - quoteResponse.quote.gasIncluded7702 || - isDelegatedAccount - ) { - const { tradeMeta, approvalMeta } = - await this.#handleEvmTransactionBatch({ - isBridgeTx, - resetApproval: quoteResponse.resetApproval, - approval: - quoteResponse.approval && - isEvmTxData(quoteResponse.approval) - ? quoteResponse.approval - : undefined, - trade: quoteResponse.trade, + // Non-blocking steps + try { + if (type === 'addHistoryItem') { + this.#addTxToHistory({ + ...payload, quoteResponse, - requireApproval, - isDelegatedAccount, + accountAddress: selectedAccount.address, + isStxEnabled: isStxEnabledOnClient, + startTime, + location, + abTests, + activeAbTests, + slippagePercentage: 0, // TODO include slippage provided by quote if using dynamic slippage, or slippage from quote request }); - - approvalTxId = approvalMeta?.id; - return tradeMeta; - } - // Set approval time and id if an approval tx is needed - const approvalTxMeta = await this.#handleApprovalTx( - quoteResponse, - isBridgeTx, - quoteResponse.quote.srcChainId, - quoteResponse.approval && isEvmTxData(quoteResponse.approval) - ? quoteResponse.approval - : undefined, - quoteResponse.resetApproval, - requireApproval, - ); - - approvalTxId = approvalTxMeta?.id; - - // Hardware-wallet delay first (Ledger second-prompt spacing), then wait for - // on-chain approval confirmation so swap gas estimation runs after allowance is set. - if (requireApproval && approvalTxMeta) { - await handleMobileHardwareWalletDelay(requireApproval); - await waitForTxConfirmation(this.messenger, approvalTxMeta.id); - } else { - await handleMobileHardwareWalletDelay(requireApproval); + } + if (type === 'startPolling') { + this.#startPollingForTxId(payload); + } + if (type === 'publishCompletedEvent') { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Completed, + payload, + ); + } + } catch (error) { + console.error( + 'Failed to add to bridge history and start polling', + error, + ); } + } - // Generate actionId for pre-submission history (non-batch EVM only) - const actionId = generateActionId().toString(); - - // Add pre-submission history keyed by actionId - // This ensures we have quote data available if transaction fails during submission - this.#addTxToHistory({ - accountAddress: selectedAccount.address, - quoteResponse, - slippagePercentage: 0, - isStxEnabled: isStxEnabledOnClient, - startTime, - approvalTxId, - location, - abTests, - activeAbTests, - actionId, - }); - - // Pass txFee when gasIncluded is true to use the quote's gas fees - // instead of re-estimating (which would fail for max native token swaps) - const tradeTxMeta = await submitEvmTransaction({ - messenger: this.messenger, - transactionType: isBridgeTx - ? TransactionType.bridge - : TransactionType.swap, - trade: quoteResponse.trade, - requireApproval, - txFee: quoteResponse.quote.gasIncluded - ? quoteResponse.quote.feeData.txFee - : undefined, - actionId, - }); - - // On success, rekey from actionId to txMeta.id and update srcTxHash - this.#rekeyHistoryItem(actionId, tradeTxMeta); - - return tradeTxMeta; - }, - ); - } + return tradeTxMeta; + }, + ); } catch (error) { - !quoteResponse.featureId && + if (!quoteResponse.featureId) { this.#trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.Failed, undefined, @@ -1160,49 +981,9 @@ export class BridgeStatusController extends StaticIntervalPollingController; activeAbTests?: { key: string; value: string }[]; + isStxEnabledOnClient?: boolean; + quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived]; }): Promise> => { - const { quoteResponse, accountAddress, location, abTests, activeAbTests } = - params; + const { + quoteResponse, + accountAddress, + location, + abTests, + activeAbTests, + isStxEnabledOnClient, + quotesReceivedContext, + } = params; // TODO add metrics context - stopPollingForQuotes(this.messenger); - - const startTime = Date.now(); - - // Build pre-confirmation properties for error tracking parity with submitTx - const account = getAccountByAddress(this.messenger, accountAddress); - const isHardwareAccount = Boolean(account) && isHardwareWallet(account); - const preConfirmationProperties = getPreConfirmationPropertiesFromQuote( + return await this.submitTx( + accountAddress, quoteResponse, - false, - isHardwareAccount, + Boolean(isStxEnabledOnClient), + quotesReceivedContext, location, abTests, activeAbTests, ); - - try { - const intent = getIntentFromQuote(quoteResponse); - - // If backend provided an approval tx for this intent quote, submit it first (on-chain), - // then proceed with off-chain intent submission. - const isBridgeTx = isCrossChain( - quoteResponse.quote.srcChainId, - quoteResponse.quote.destChainId, - ); - - const requireApproval = - isHardwareAccount && this.#clientId === BridgeClientId.MOBILE; - // Handle approval silently for better UX in intent flows - const approvalTxMeta = await this.#handleApprovalTx( - quoteResponse, - isBridgeTx, - quoteResponse.quote.srcChainId, - quoteResponse.approval, - quoteResponse.resetApproval, - requireApproval, - ); - - const approvalTxId = approvalTxMeta?.id; - - if (approvalTxId) { - await waitForTxConfirmation(this.messenger, approvalTxId); - } - - const { srcChainId, requestId } = quoteResponse.quote; - - const signature = await signTypedMessage({ - messenger: this.messenger, - accountAddress, - typedData: intent.typedData, - }); - - const submissionParams: IntentSubmissionParams = { - srcChainId, - quoteId: requestId, - signature, - order: intent.order, - userAddress: accountAddress, - aggregatorId: intent.protocol, - }; - - const { id: orderUid, status } = await postSubmitOrder({ - params: submissionParams, - clientId: this.#clientId, - jwt: await getJwt(this.messenger), - fetchFn: this.#fetchFn, - bridgeApiBaseUrl: this.#config.customBridgeApiBaseUrl, - }); - - // Determine transaction type: swap for same-chain, bridge for cross-chain - const transactionType = isBridgeTx - ? /* c8 ignore start */ - TransactionType.bridge - : /* c8 ignore end */ - TransactionType.swap; - - // Create actual transaction in Transaction Controller first - const networkClientId = getNetworkClientIdByChainId( - this.messenger, - srcChainId, - ); - - // This is a synthetic transaction whose purpose is to be able - // to track the order status via the history - const intentTransactionParams = { - chainId: formatChainIdToHex(srcChainId), - from: accountAddress, - to: - intent.settlementContract ?? - '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', // Default settlement contract - data: `0x${orderUid.slice(-8)}`, // Use last 8 chars of orderUid to make each transaction unique - value: '0x0', - gas: '0x5208', // Minimal gas for display purposes - gasPrice: '0x3b9aca00', // 1 Gwei - will be converted to EIP-1559 fees if network supports it - }; - - const initialTxMeta = await addSyntheticTransaction( - this.messenger, - intentTransactionParams, - { - requireApproval: false, - networkClientId, - type: transactionType, - }, - ); - - // Update txHistory with actual transaction metadata - const syntheticMeta = { - ...initialTxMeta, - // Map intent order status to TransactionController status - status: mapIntentOrderStatusToTransactionStatus(status), - isIntentTx: true, - orderUid, - }; - - // Record in bridge history with actual transaction metadata - try { - // Use orderId as the history key for intent transactions - const bridgeHistoryKey = orderUid; - - // Create a bridge transaction metadata that includes the original txId - const bridgeTxMetaForHistory = { - ...syntheticMeta, - id: bridgeHistoryKey, - originalTransactionId: syntheticMeta.id, // Keep original txId for TransactionController updates - }; - - this.#addTxToHistory({ - accountAddress, - bridgeTxMeta: bridgeTxMetaForHistory, - quoteResponse, - slippagePercentage: 0, - isStxEnabled: false, - approvalTxId, - startTime, - location, - abTests, - activeAbTests, - }); - - // Start polling using the orderId key to route to intent manager - this.#startPollingForTxId(bridgeHistoryKey); - } catch (error) { - console.error( - '📝 [submitIntent] Failed to add to bridge history', - error, - ); - // non-fatal but log the error - } - return syntheticMeta; - } catch (error) { - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Failed, - undefined, - { - error_message: (error as Error)?.message, - ...preConfirmationProperties, - }, - ); - - throw error; - } }; /** diff --git a/packages/bridge-status-controller/src/strategy/evm-strategy.ts b/packages/bridge-status-controller/src/strategy/evm-strategy.ts index 232ce4c4b7..5b3ac91ad0 100644 --- a/packages/bridge-status-controller/src/strategy/evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/evm-strategy.ts @@ -11,6 +11,7 @@ import { handleApprovalDelay, handleMobileHardwareWalletDelay, submitEvmTransaction, + waitForTxConfirmation, } from '../utils/transaction'; /** @@ -105,9 +106,10 @@ export async function* submitEvmHandler( }, ); // Delay after approval + await handleMobileHardwareWalletDelay(requireApproval); if (approvalTxId) { await handleApprovalDelay(quoteResponse.quote.srcChainId); - await handleMobileHardwareWalletDelay(requireApproval); + await waitForTxConfirmation(args.messenger, approvalTxId); } // Generate trade actionId for pre-submission history diff --git a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts index 41d93cfa73..552d8849bb 100644 --- a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts @@ -58,10 +58,6 @@ export async function* submitNonEvmHandler( args: SubmitStrategyParams, ): AsyncGenerator { const { quoteResponse, isBridgeTx } = args; - yield { - type: 'publishFailedEvent', - payload: true, - }; if ( !( isTronTrade(quoteResponse.trade) || diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index 5102a18bb8..fbdcfd407e 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -20,10 +20,6 @@ import type { * Any possible result returned by steps in a submission strategy. These can be returned in any order. */ export type SubmitStepResult = - | { - type: 'publishFailedEvent'; - payload: boolean; - } | { type: 'addHistoryItem'; payload: Pick< From ba9471055c2dad9d17177aa84986866465126882 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Mon, 23 Mar 2026 10:14:21 -0700 Subject: [PATCH 03/16] chore: rename rename rename wip fix: merge conflict --- .../src/bridge-status-controller.ts | 30 ++++++++++--------- .../src/strategy/batch-strategy.ts | 14 ++------- .../src/strategy/evm-strategy.ts | 7 ++++- .../src/strategy/index.ts | 10 ++++--- .../src/strategy/types.ts | 5 ++-- .../src/utils/metrics.ts | 2 +- 6 files changed, 35 insertions(+), 33 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 2e7e498042..e8b98c13ba 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -831,7 +831,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, - isStxEnabledOnClient: boolean, + isStxEnabled: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.MainView, abTests?: Record, @@ -876,7 +876,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { /** * Check if the account is an EIP-7702 delegated account. @@ -913,7 +913,7 @@ export class BridgeStatusController extends StaticIntervalPollingController; activeAbTests?: { key: string; value: string }[]; - isStxEnabledOnClient?: boolean; + isStxEnabled?: boolean; quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived]; }): Promise> => { const { @@ -1016,7 +1016,7 @@ export class BridgeStatusController extends StaticIntervalPollingController 0 && { ab_tests: resolvedAbTests, diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts index 29db022c6b..4f343c1963 100644 --- a/packages/bridge-status-controller/src/strategy/batch-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -1,4 +1,4 @@ -import { isEvmTxData } from '@metamask/bridge-controller'; +import { TxData } from '@metamask/bridge-controller'; import type { SubmitStrategyParams, SubmitStepResult } from './types'; import { @@ -13,7 +13,7 @@ import { * @yields The approvalMeta and tradeMeta for the batched transaction */ export async function* submitBatchHandler( - args: SubmitStrategyParams, + args: SubmitStrategyParams, ): AsyncGenerator { const { requireApproval, @@ -22,19 +22,11 @@ export async function* submitBatchHandler( isBridgeTx, addTransactionBatchFn, } = args; - if (!isEvmTxData(quoteResponse.trade)) { - throw new Error( - 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', - ); - } const transactionParams = await getAddTransactionBatchParams({ messenger, isBridgeTx, resetApproval: quoteResponse.resetApproval, - approval: - quoteResponse.approval && isEvmTxData(quoteResponse.approval) - ? quoteResponse.approval - : undefined, + approval: quoteResponse.approval, trade: quoteResponse.trade, quoteResponse, requireApproval, diff --git a/packages/bridge-status-controller/src/strategy/evm-strategy.ts b/packages/bridge-status-controller/src/strategy/evm-strategy.ts index 5b3ac91ad0..141563c829 100644 --- a/packages/bridge-status-controller/src/strategy/evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/evm-strategy.ts @@ -105,10 +105,15 @@ export async function* submitEvmHandler( return await handleEvmApprovals(args); }, ); + // Delay after approval - await handleMobileHardwareWalletDelay(requireApproval); if (approvalTxId) { await handleApprovalDelay(quoteResponse.quote.srcChainId); + } + // Hardware-wallet delay first (Ledger second-prompt spacing), then wait for + // on-chain approval confirmation so swap gas estimation runs after allowance is set. + await handleMobileHardwareWalletDelay(requireApproval); + if (requireApproval && approvalTxId) { await waitForTxConfirmation(args.messenger, approvalTxId); } diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts index c6176a4fff..64a7752e97 100644 --- a/packages/bridge-status-controller/src/strategy/index.ts +++ b/packages/bridge-status-controller/src/strategy/index.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { isNonEvmChainId } from '@metamask/bridge-controller'; +import { isEvmTxData, isNonEvmChainId } from '@metamask/bridge-controller'; import { submitBatchHandler } from './batch-strategy'; import { submitEvmHandler as defaultSubmitHandler } from './evm-strategy'; @@ -25,9 +25,11 @@ const SUBMIT_STRATEGY_REGISTRY: SubmitStrategy[] = [ const { quoteResponse, isStxEnabledOnClient, isDelegatedAccount } = params; return ( - isStxEnabledOnClient || - quoteResponse.quote.gasIncluded7702 || - isDelegatedAccount + (isStxEnabledOnClient || + quoteResponse.quote.gasIncluded7702 || + isDelegatedAccount) && + isEvmTxData(quoteResponse.trade) && + (quoteResponse.approval ? isEvmTxData(quoteResponse.approval) : true) ); }, execute: submitBatchHandler, diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index fbdcfd407e..3376bb1489 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -3,6 +3,7 @@ import type { BridgeClientId, QuoteMetadata, QuoteResponse, + Trade, } from '@metamask/bridge-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import type { @@ -55,13 +56,13 @@ export type SubmitStepResult = /** * The parameters for the submission flow */ -export type SubmitStrategyParams = { +export type SubmitStrategyParams = { addTransactionBatchFn: TransactionController['addTransactionBatch']; isBridgeTx: boolean; isDelegatedAccount: boolean; isStxEnabledOnClient: boolean; messenger: BridgeStatusControllerMessenger; - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponse & QuoteMetadata; requireApproval: boolean; selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string]; traceFn: TraceCallback; diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index e24188fb11..699c742584 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -185,7 +185,7 @@ export const getPreConfirmationPropertiesFromQuote = ( quoteResponse: QuoteResponse & Partial, isStxEnabledOnClient: boolean, isHardwareAccount: boolean, - location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.MainView, + location?: MetaMetricsSwapsEventSource, abTests?: Record, activeAbTests?: { key: string; value: string }[], ) => { From 761a5eeb8ecb0b19055c65f12a56f595c9d9fbd4 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 7 Apr 2026 10:07:25 -0700 Subject: [PATCH 04/16] fix: comments --- .../src/strategy/intent-strategy.ts | 13 +++++++++++++ .../src/strategy/non-evm-strategy.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/bridge-status-controller/src/strategy/intent-strategy.ts b/packages/bridge-status-controller/src/strategy/intent-strategy.ts index 62d03cde10..53a0cd87ce 100644 --- a/packages/bridge-status-controller/src/strategy/intent-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/intent-strategy.ts @@ -128,6 +128,19 @@ const handleSubmitIntent = async (args: SubmitStrategyParams) => { }; }; +/** + * Submits an approval tx to the TransactionController, + * posts an intent order to the bridge-api, + * and creates a synthetic transaction in the TransactionController + * + * @param args - The parameters for the transaction + * @param args.quoteResponse - The quote response + * @param args.messenger - The messenger + * @param args.selectedAccount - The selected account + * @param args.traceFn - The trace function + * @param args.isBridgeTx - Whether the transaction is a bridge transaction + * @yields The approvalTxId and tradeMeta for the intent transaction + */ export async function* submitIntentHandler( args: SubmitStrategyParams, ): AsyncGenerator { diff --git a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts index 552d8849bb..a627897ffe 100644 --- a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts @@ -44,7 +44,7 @@ const handleTronApproval = async (args: SubmitStrategyParams) => { }; /** - * Submits batched EVM transactions to the TransactionController + * Submits Solana, Bitcoin, or Tron transactions to the snap controller * * @param args - The parameters for the transaction * @param args.quoteResponse - The quote response From e97bc418e6c84c47b4b81f5f87ab3da0094d59d3 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 7 Apr 2026 11:24:24 -0700 Subject: [PATCH 05/16] chore: move tx data validation --- .../src/bridge-status-controller.ts | 2 +- .../src/strategy/evm-strategy.ts | 7 +- .../src/strategy/index.ts | 112 +++++++++++------- .../src/strategy/intent-strategy.ts | 10 +- .../src/strategy/non-evm-strategy.ts | 32 +++-- .../src/strategy/types.ts | 3 +- 6 files changed, 94 insertions(+), 72 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index e8b98c13ba..bdbd54e553 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -911,7 +911,7 @@ export class BridgeStatusController extends StaticIntervalPollingController = { quoteResponse, isStxEnabledOnClient: isStxEnabled, isDelegatedAccount, diff --git a/packages/bridge-status-controller/src/strategy/evm-strategy.ts b/packages/bridge-status-controller/src/strategy/evm-strategy.ts index 141563c829..df9e4d0cf2 100644 --- a/packages/bridge-status-controller/src/strategy/evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/evm-strategy.ts @@ -83,7 +83,7 @@ export const handleEvmApprovals = async (args: SubmitStrategyParams) => { * @yields Data for updating the BridgeStatusController */ export async function* submitEvmHandler( - args: SubmitStrategyParams, + args: SubmitStrategyParams, ): AsyncGenerator { const { quoteResponse, @@ -92,11 +92,6 @@ export async function* submitEvmHandler( isStxEnabledOnClient, isBridgeTx, } = args; - if (!isEvmTxData(quoteResponse.trade)) { - throw new Error( - 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', - ); - } // Submit resetApproval and approval transactions if present const approvalTxId = await traceFn( diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts index 64a7752e97..bb2fb9e0f7 100644 --- a/packages/bridge-status-controller/src/strategy/index.ts +++ b/packages/bridge-status-controller/src/strategy/index.ts @@ -1,47 +1,44 @@ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { isEvmTxData, isNonEvmChainId } from '@metamask/bridge-controller'; +import { + BitcoinTradeData, + ChainId, + isBitcoinTrade, + isEvmTxData, + isNonEvmChainId, + isTronTrade, + Trade, + TronTradeData, + TxData, +} from '@metamask/bridge-controller'; import { submitBatchHandler } from './batch-strategy'; import { submitEvmHandler as defaultSubmitHandler } from './evm-strategy'; import { submitIntentHandler } from './intent-strategy'; import { submitNonEvmHandler } from './non-evm-strategy'; -import type { - SubmitStrategyParams, - SubmitStepResult, - SubmitStrategy, -} from './types'; - -const SUBMIT_STRATEGY_REGISTRY: SubmitStrategy[] = [ - { - matchesFlow: (params: SubmitStrategyParams) => { - const { quoteResponse } = params; - return isNonEvmChainId(quoteResponse.quote.srcChainId); - }, - execute: submitNonEvmHandler, - }, - { - matchesFlow: (params: SubmitStrategyParams) => { - const { quoteResponse, isStxEnabledOnClient, isDelegatedAccount } = - params; - return ( - (isStxEnabledOnClient || - quoteResponse.quote.gasIncluded7702 || - isDelegatedAccount) && - isEvmTxData(quoteResponse.trade) && - (quoteResponse.approval ? isEvmTxData(quoteResponse.approval) : true) - ); - }, - execute: submitBatchHandler, - }, - { - matchesFlow: (params: SubmitStrategyParams) => { - const { quoteResponse } = params; - return Boolean(quoteResponse.quote.intent); - }, - execute: submitIntentHandler, - }, -]; +import type { SubmitStrategyParams, SubmitStepResult } from './types'; + +const validateParams = < + TxDataType extends BitcoinTradeData | TronTradeData | string | TxData, +>( + params: SubmitStrategyParams, +): params is SubmitStrategyParams => { + const txs = [ + params.quoteResponse.trade, + params.quoteResponse.approval, + params.quoteResponse.resetApproval, + ].filter((tx): tx is TxDataType => tx !== undefined); + + switch (params.quoteResponse.quote.srcChainId) { + case ChainId.SOLANA: + return txs.every((tx) => typeof tx === 'string'); + case ChainId.BTC: + return txs.every(isBitcoinTrade); + case ChainId.TRON: + return txs.every(isTronTrade); + default: + return txs.every(isEvmTxData); + } +}; /** * Selects the appropriate submit strategy based on the quote parameters and executes it @@ -51,12 +48,41 @@ const SUBMIT_STRATEGY_REGISTRY: SubmitStrategy[] = [ * results are used to update the BridgeStatusController state and emit events. */ const executeSubmitFlow = ( - params: SubmitStrategyParams, + params: SubmitStrategyParams, ): AsyncGenerator => { - return ( - SUBMIT_STRATEGY_REGISTRY.find((strategy) => strategy.matchesFlow(params)) - ?.execute ?? defaultSubmitHandler - )(params); + const { quoteResponse, isStxEnabledOnClient, isDelegatedAccount } = params; + + // Non-EVM transactions + if (isNonEvmChainId(quoteResponse.quote.srcChainId)) { + if (!validateParams(params)) { + throw new Error( + 'Failed to submit cross-chain swap transaction: trade is not a non-EVM transaction', + ); + } + return submitNonEvmHandler(params); + } + + // EVM transactions + if (!validateParams(params)) { + throw new Error( + 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', + ); + } + + if (quoteResponse.quote.intent) { + return submitIntentHandler(params); + } + + const shouldBatchTxs = + isStxEnabledOnClient || + quoteResponse.quote.gasIncluded7702 || + isDelegatedAccount; + + if (shouldBatchTxs) { + return submitBatchHandler(params); + } + + return defaultSubmitHandler(params); }; export default executeSubmitFlow; diff --git a/packages/bridge-status-controller/src/strategy/intent-strategy.ts b/packages/bridge-status-controller/src/strategy/intent-strategy.ts index 53a0cd87ce..10848f9ead 100644 --- a/packages/bridge-status-controller/src/strategy/intent-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/intent-strategy.ts @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { formatChainIdToHex, isEvmTxData } from '@metamask/bridge-controller'; +import { + formatChainIdToHex, + isEvmTxData, + TxData, +} from '@metamask/bridge-controller'; import { TransactionType } from '@metamask/transaction-controller'; import { handleEvmApprovals } from './evm-strategy'; @@ -89,7 +93,7 @@ const handleSyntheticTx = async ( * @param args.isBridgeTx - Whether the transaction is a bridge transaction * @returns The approvalTxId and tradeMeta for the non-EVM transaction */ -const handleSubmitIntent = async (args: SubmitStrategyParams) => { +const handleSubmitIntent = async (args: SubmitStrategyParams) => { const { quoteResponse, messenger, @@ -142,7 +146,7 @@ const handleSubmitIntent = async (args: SubmitStrategyParams) => { * @yields The approvalTxId and tradeMeta for the intent transaction */ export async function* submitIntentHandler( - args: SubmitStrategyParams, + args: SubmitStrategyParams, ): AsyncGenerator { // TODO handle STX/batch approvals const approvalTxId = await handleEvmApprovals(args); diff --git a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts index a627897ffe..ee4b2ea605 100644 --- a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { - isBitcoinTrade, - isTronChainId, - isTronTrade, +import { isTronChainId } from '@metamask/bridge-controller'; +import type { + BitcoinTradeData, + TronTradeData, + TxData, } from '@metamask/bridge-controller'; import type { SubmitStrategyParams, SubmitStepResult } from './types'; @@ -16,13 +17,17 @@ import { handleApprovalDelay } from '../utils/transaction'; * @param args - The parameters for the transaction * @returns The tx id of the approval transaction */ -const handleTronApproval = async (args: SubmitStrategyParams) => { +const handleTronApproval = async ( + args: SubmitStrategyParams< + TronTradeData | BitcoinTradeData | string | TxData + >, +) => { const { quoteResponse, traceFn } = args; const approvalTxId = await traceFn( getApprovalTraceParams(quoteResponse, false), async () => { - if (quoteResponse.approval && isTronTrade(quoteResponse.approval)) { + if (quoteResponse.approval) { const txMeta = await handleNonEvmTx( args.messenger, quoteResponse.approval, @@ -55,20 +60,11 @@ const handleTronApproval = async (args: SubmitStrategyParams) => { * @yields The approvalTxId and tradeMeta for the non-EVM transaction */ export async function* submitNonEvmHandler( - args: SubmitStrategyParams, + args: SubmitStrategyParams< + BitcoinTradeData | TronTradeData | string | TxData + >, ): AsyncGenerator { const { quoteResponse, isBridgeTx } = args; - if ( - !( - isTronTrade(quoteResponse.trade) || - isBitcoinTrade(quoteResponse.trade) || - typeof quoteResponse.trade === 'string' - ) - ) { - throw new Error( - 'Failed to submit cross-chain swap transaction: trade is not a non-EVM transaction', - ); - } const approvalTxId = await handleTronApproval(args); diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index 3376bb1489..d3d5f52f01 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -4,6 +4,7 @@ import type { QuoteMetadata, QuoteResponse, Trade, + TxData, } from '@metamask/bridge-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import type { @@ -56,7 +57,7 @@ export type SubmitStepResult = /** * The parameters for the submission flow */ -export type SubmitStrategyParams = { +export type SubmitStrategyParams = { addTransactionBatchFn: TransactionController['addTransactionBatch']; isBridgeTx: boolean; isDelegatedAccount: boolean; From 8f243cc8143624884155602f8eb5ab9e94cf8817 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 7 Apr 2026 16:19:50 -0700 Subject: [PATCH 06/16] chore: specify history keys history --- .../src/bridge-status-controller.ts | 24 +++++--- .../src/strategy/batch-strategy.ts | 1 + .../src/strategy/evm-strategy.ts | 4 +- .../src/strategy/intent-strategy.ts | 3 +- .../src/strategy/non-evm-strategy.ts | 1 + .../src/strategy/types.ts | 12 ++-- .../src/utils/history.test.ts | 8 ++- .../src/utils/history.ts | 58 ++++++++++--------- 8 files changed, 66 insertions(+), 45 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index bdbd54e553..90f7818c62 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -455,10 +455,10 @@ export class BridgeStatusController extends StaticIntervalPollingController ): void => { - // Use actionId as key for pre-submission, or txMeta.id for post-submission - const { historyKey, txHistoryItem } = getInitialHistoryItem(...args); + const txHistoryItem = getInitialHistoryItem(...args); this.update((state) => { state.txHistory[historyKey] = txHistoryItem; }); @@ -468,17 +468,19 @@ export class BridgeStatusController extends StaticIntervalPollingController { this.update((state) => { - rekeyHistoryItemInState(state, actionId, txMeta); + rekeyHistoryItemInState(state, oldKey, newKey, txMeta); }); }; @@ -521,7 +523,7 @@ export class BridgeStatusController extends StaticIntervalPollingController; + > & { + historyKey: string; + }; } | { type: 'rekeyHistoryItem'; payload: { - /** The actionId of the preceeding `approval` transaction */ - actionId: string; + /** Usually the actionId of the preceeding `approval` transaction */ + oldKey: string; + /** Usually the txMeta.id of the `trade` transaction */ + newKey: string; /** The {@link TransactionMeta} for the `trade` transaction after it has been submitted successfully */ - tradeMeta: TransactionMeta; + tradeMeta?: TransactionMeta; }; } | { diff --git a/packages/bridge-status-controller/src/utils/history.test.ts b/packages/bridge-status-controller/src/utils/history.test.ts index 56944509ec..8c227403d8 100644 --- a/packages/bridge-status-controller/src/utils/history.test.ts +++ b/packages/bridge-status-controller/src/utils/history.test.ts @@ -20,7 +20,7 @@ describe('History Utils', () => { it('returns false when history item missing', () => { const state = makeState(); - const result = rekeyHistoryItemInState(state, 'missing', { + const result = rekeyHistoryItemInState(state, 'missing', 'tx1', { id: 'tx1', hash: '0xhash', }); @@ -47,7 +47,7 @@ describe('History Utils', () => { }, }); - const result = rekeyHistoryItemInState(state, 'action1', { + const result = rekeyHistoryItemInState(state, 'action1', 'tx1', { id: 'tx1', hash: '0xnew', }); @@ -77,7 +77,9 @@ describe('History Utils', () => { }, }); - const result = rekeyHistoryItemInState(state, 'action1', { id: 'tx1' }); + const result = rekeyHistoryItemInState(state, 'action1', 'tx1', { + id: 'tx1', + }); expect(result).toBe(true); expect(state.txHistory.tx1.status.srcChain.txHash).toBe('0xold'); diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index 9a7c1921b7..8600fe1feb 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -10,29 +10,43 @@ import type { StartPollingForBridgeTxStatusArgsSerialized, } from '../types'; +const updateHistoryItem = ( + oldHistoryItem: BridgeHistoryItem, + txMeta?: { id: string; hash?: string }, +): Partial => { + if (!txMeta) { + return {}; + } + return { + ...oldHistoryItem, + txMetaId: txMeta.id, + originalTransactionId: oldHistoryItem.originalTransactionId ?? txMeta.id, + status: { + ...oldHistoryItem.status, + srcChain: { + ...oldHistoryItem.status.srcChain, + txHash: txMeta.hash ?? oldHistoryItem.status.srcChain?.txHash, + }, + }, + }; +}; + export const rekeyHistoryItemInState = ( state: BridgeStatusControllerState, - actionId: string, - txMeta: { id: string; hash?: string }, + oldKey: string, + newKey: string, + txMeta?: { id: string; hash?: string }, ): boolean => { - const historyItem = state.txHistory[actionId]; + const historyItem = state.txHistory[oldKey]; if (!historyItem) { return false; } - state.txHistory[txMeta.id] = { + state.txHistory[newKey] = { ...historyItem, - txMetaId: txMeta.id, - originalTransactionId: historyItem.originalTransactionId ?? txMeta.id, - status: { - ...historyItem.status, - srcChain: { - ...historyItem.status.srcChain, - txHash: txMeta.hash ?? historyItem.status.srcChain?.txHash, - }, - }, + ...updateHistoryItem(historyItem, txMeta), }; - delete state.txHistory[actionId]; + delete state.txHistory[oldKey]; return true; }; @@ -40,6 +54,7 @@ export const rekeyHistoryItemInState = ( * Determines the key to use for storing a bridge history item. * Uses actionId for pre-submission tracking, or bridgeTxMetaId for post-submission. * + * @deprecated specify an explicit history key instead * @param actionId - The action ID used for pre-submission tracking * @param bridgeTxMetaId - The transaction meta ID from bridgeTxMeta * @param syntheticTransactionId - The transactionId of the intent's placeholder transaction @@ -62,10 +77,7 @@ export function getHistoryKey( export const getInitialHistoryItem = ( args: StartPollingForBridgeTxStatusArgsSerialized, -): { - historyKey: string; - txHistoryItem: BridgeHistoryItem; -} => { +): BridgeHistoryItem => { const { bridgeTxMeta, quoteResponse, @@ -82,14 +94,6 @@ export const getInitialHistoryItem = ( originalTransactionId, actionId, } = args; - // Determine the key for this history item: - // - For pre-submission (non-batch EVM): use actionId - // - For post-submission or other cases: use bridgeTxMeta.id - const historyKey = getHistoryKey( - actionId, - bridgeTxMeta?.id, - originalTransactionId, - ); // Write all non-status fields to state so we can reference the quote in Activity list without the Bridge API // We know it's in progress but not the exact status yet @@ -131,7 +135,7 @@ export const getInitialHistoryItem = ( ...(activeAbTests && { activeAbTests }), }; - return { historyKey, txHistoryItem }; + return txHistoryItem; }; export const shouldPollHistoryItem = ( From babd6f6b2629a98fda2b5d89993f544b0df8688d Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 7 Apr 2026 16:29:23 -0700 Subject: [PATCH 07/16] refactor: gasless params batch transaction util refactor refactor: simplify batch tranaction fix --- .../bridge-status-controller.test.ts.snap | 1 + .../src/strategy/batch-strategy.ts | 42 +++- .../src/strategy/index.ts | 6 +- .../src/utils/gas.test.ts | 13 -- .../src/utils/transaction.test.ts | 137 +++++++++---- .../src/utils/transaction.ts | 189 ++++++++---------- 6 files changed, 220 insertions(+), 168 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 d7ab89a99d..d2c35c234f 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 @@ -3859,6 +3859,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti "requireApproval": false, "transactions": [ { + "assetsFiatValues": undefined, "params": { "data": "0xapprovalData", "from": "0xaccount1", diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts index e7e3f78df6..bb59ccf924 100644 --- a/packages/bridge-status-controller/src/strategy/batch-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -1,4 +1,5 @@ import { TxData } from '@metamask/bridge-controller'; +import { TransactionType } from '@metamask/transaction-controller'; import type { SubmitStrategyParams, SubmitStepResult } from './types'; import { @@ -21,15 +22,46 @@ export async function* submitBatchHandler( messenger, isBridgeTx, addTransactionBatchFn, + isDelegatedAccount, } = args; + + const tradeData: Parameters< + typeof getAddTransactionBatchParams + >[0]['tradeData'] = []; + + if (quoteResponse.resetApproval) { + tradeData.push({ + tx: quoteResponse.resetApproval, + type: isBridgeTx + ? TransactionType.bridgeApproval + : TransactionType.swapApproval, + }); + } + if (quoteResponse.approval) { + tradeData.push({ + tx: quoteResponse.approval, + type: isBridgeTx + ? TransactionType.bridgeApproval + : TransactionType.swapApproval, + }); + } + if (quoteResponse.trade) { + tradeData.push({ + tx: quoteResponse.trade, + type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, + assetsFiatValues: { + sending: quoteResponse.sentAmount?.valueInCurrency?.toString(), + receiving: quoteResponse.toTokenAmount?.valueInCurrency?.toString(), + }, + }); + } + const transactionParams = await getAddTransactionBatchParams({ messenger, - isBridgeTx, - resetApproval: quoteResponse.resetApproval, - approval: quoteResponse.approval, - trade: quoteResponse.trade, - quoteResponse, + tradeData, + quote: quoteResponse.quote, requireApproval, + isDelegatedAccount, }); const { approvalMeta, tradeMeta } = await addTransactionBatch( diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts index bb2fb9e0f7..c6d73ca0bc 100644 --- a/packages/bridge-status-controller/src/strategy/index.ts +++ b/packages/bridge-status-controller/src/strategy/index.ts @@ -41,7 +41,7 @@ const validateParams = < }; /** - * Selects the appropriate submit strategy based on the quote parameters and executes it + * Selects the appropriate submit strategy based on the quote parameters then executes it * * @param params - The parameters for the transaction * @returns An async generator that yields results from each step of the submit flow. The yielded @@ -69,19 +69,21 @@ const executeSubmitFlow = ( ); } + // Intent transactions if (quoteResponse.quote.intent) { return submitIntentHandler(params); } + // Batched transactions const shouldBatchTxs = isStxEnabledOnClient || quoteResponse.quote.gasIncluded7702 || isDelegatedAccount; - if (shouldBatchTxs) { return submitBatchHandler(params); } + // Non-stx/gasless EVM transactions return defaultSubmitHandler(params); }; diff --git a/packages/bridge-status-controller/src/utils/gas.test.ts b/packages/bridge-status-controller/src/utils/gas.test.ts index 5967defba9..fe1cb9f3bf 100644 --- a/packages/bridge-status-controller/src/utils/gas.test.ts +++ b/packages/bridge-status-controller/src/utils/gas.test.ts @@ -155,20 +155,8 @@ describe('gas calculation utils', () => { value: '0x1', }; - it('should return empty object if gas fields should be skipped (skipGasFields is true)', async () => { - const result = await calculateGasFees( - true, - null as never, - mockTrade, - 'mainnet', - '0x1', - ); - expect(result).toStrictEqual({}); - }); - it('should txFee when provided', async () => { const result = await calculateGasFees( - false, null as never, mockTrade, 'mainnet', @@ -211,7 +199,6 @@ describe('gas calculation utils', () => { }, }); const result = await calculateGasFees( - false, { call: mockCall } as never, { ...mockTrade, gasLimit }, 'mainnet', diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index 8b87cf848b..2f25a77524 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -1665,7 +1665,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); describe('toBatchTxParams', () => { - it('should return params without gas if skipGasFields is true', () => { + it('should return params without gas if gasFees are undefined', () => { const mockTrade = { chainId: 1, gasLimit: 1231, @@ -1674,7 +1674,7 @@ describe('Bridge Status Controller Transaction Utils', () => { from: '0x1', value: '0x1', }; - const result = toBatchTxParams(true, mockTrade as TxData, {}); + const result = toBatchTxParams(mockTrade as TxData); expect(result).toStrictEqual({ data: '0x1', from: '0x1', @@ -1701,8 +1701,7 @@ describe('Bridge Status Controller Transaction Utils', () => { includeApproval?: boolean; includeResetApproval?: boolean; } = {}, - ): QuoteResponse & - QuoteMetadata & { approval?: TxData; resetApproval?: TxData } => + ): QuoteResponse & QuoteMetadata => ({ quote: { bridgeId: 'bridge1', @@ -1745,6 +1744,7 @@ describe('Bridge Status Controller Transaction Utils', () => { to: '0xTokenContract', data: '0xapprovalData', from: '0xUserAddress', + chainId: ChainId.ETH, }, }), ...(overrides.includeResetApproval && { @@ -1752,6 +1752,7 @@ describe('Bridge Status Controller Transaction Utils', () => { to: '0xTokenContract', data: '0xresetData', from: '0xUserAddress', + chainId: ChainId.ETH, }, }), sentAmount: { @@ -1817,11 +1818,19 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quoteResponse: mockQuoteResponse, + quote: mockQuoteResponse.quote, messenger: mockMessagingSystem, - isBridgeTx: true, - trade: mockQuoteResponse.trade, - approval: mockQuoteResponse.approval, + tradeData: [ + { + tx: mockQuoteResponse.approval as TxData, + type: TransactionType.bridgeApproval, + }, + { + tx: mockQuoteResponse.trade, + type: TransactionType.bridge, + }, + ], + isDelegatedAccount: false, }); expect(result.disable7702).toBe(false); @@ -1839,10 +1848,15 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quoteResponse: mockQuoteResponse, + quote: mockQuoteResponse.quote, messenger: mockMessagingSystem, - isBridgeTx: false, - trade: mockQuoteResponse.trade, + tradeData: [ + { + tx: mockQuoteResponse.trade, + type: TransactionType.swap, + }, + ], + isDelegatedAccount: false, }); expect(result.disable7702).toBe(true); @@ -1859,11 +1873,19 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quoteResponse: mockQuoteResponse, + quote: mockQuoteResponse.quote, messenger: mockMessagingSystem, - isBridgeTx: false, - trade: mockQuoteResponse.trade, - approval: mockQuoteResponse.approval, + tradeData: [ + { + tx: mockQuoteResponse.approval as TxData, + type: TransactionType.swapApproval, + }, + { + tx: mockQuoteResponse.trade, + type: TransactionType.swap, + }, + ], + isDelegatedAccount: false, }); expect(result.transactions).toHaveLength(2); @@ -1877,11 +1899,19 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quoteResponse: mockQuoteResponse, + quote: mockQuoteResponse.quote, messenger: mockMessagingSystem, - isBridgeTx: false, - trade: mockQuoteResponse.trade, - resetApproval: mockQuoteResponse.resetApproval, + tradeData: [ + { + tx: mockQuoteResponse.resetApproval as TxData, + type: TransactionType.swapApproval, + }, + { + tx: mockQuoteResponse.trade, + type: TransactionType.swap, + }, + ], + isDelegatedAccount: false, }); expect(result.transactions).toHaveLength(2); @@ -1897,11 +1927,19 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quoteResponse: mockQuoteResponse, + quote: mockQuoteResponse.quote, messenger: mockMessagingSystem, - isBridgeTx: true, - trade: mockQuoteResponse.trade, - resetApproval: mockQuoteResponse.resetApproval, + tradeData: [ + { + tx: mockQuoteResponse.resetApproval as TxData, + type: TransactionType.bridgeApproval, + }, + { + tx: mockQuoteResponse.trade, + type: TransactionType.bridge, + }, + ], + isDelegatedAccount: false, }); expect(result.disable7702).toBe(true); @@ -1919,10 +1957,15 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quoteResponse: mockQuoteResponse, + quote: mockQuoteResponse.quote, messenger: mockMessagingSystem, - isBridgeTx: false, - trade: mockQuoteResponse.trade, + tradeData: [ + { + tx: mockQuoteResponse.trade, + type: TransactionType.swap, + }, + ], + isDelegatedAccount: false, }); expect(result.isGasFeeIncluded).toBe(false); @@ -1935,10 +1978,15 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quoteResponse: mockQuoteResponse, + quote: mockQuoteResponse.quote, messenger: mockMessagingSystem, - isBridgeTx: false, - trade: mockQuoteResponse.trade, + tradeData: [ + { + tx: mockQuoteResponse.trade, + type: TransactionType.swap, + }, + ], + isDelegatedAccount: false, }); expect(result.isGasFeeIncluded).toBe(true); @@ -1951,10 +1999,15 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quoteResponse: mockQuoteResponse, + quote: mockQuoteResponse.quote, messenger: mockMessagingSystem, - isBridgeTx: false, - trade: mockQuoteResponse.trade, + tradeData: [ + { + tx: mockQuoteResponse.trade, + type: TransactionType.swap, + }, + ], + isDelegatedAccount: false, }); expect(result.isGasFeeIncluded).toBe(false); @@ -1977,10 +2030,14 @@ describe('Bridge Status Controller Transaction Utils', () => { const callSpy = jest.spyOn(mockMessenger, 'call'); const result = await getAddTransactionBatchParams({ - quoteResponse: mockQuoteResponse, + quote: mockQuoteResponse.quote, messenger: mockMessenger, - isBridgeTx: true, - trade: mockQuoteResponse.trade, + tradeData: [ + { + tx: mockQuoteResponse.trade as TxData, + type: TransactionType.bridge, + }, + ], isDelegatedAccount: true, }); @@ -2033,10 +2090,14 @@ describe('Bridge Status Controller Transaction Utils', () => { const callSpy = jest.spyOn(mockMessagingSystem, 'call'); const result = await getAddTransactionBatchParams({ - quoteResponse: mockQuoteResponse, + quote: mockQuoteResponse.quote, messenger: mockMessagingSystem, - isBridgeTx: true, - trade: mockQuoteResponse.trade, + tradeData: [ + { + tx: mockQuoteResponse.trade as TxData, + type: TransactionType.bridge, + }, + ], isDelegatedAccount: true, }); diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 95e3a99164..1304109aad 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -1,14 +1,11 @@ +/* eslint-disable no-restricted-syntax */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { ChainId, formatChainIdToHex, BRIDGE_PREFERRED_GAS_ESTIMATE, } from '@metamask/bridge-controller'; -import type { - QuoteMetadata, - QuoteResponse, - TxData, -} from '@metamask/bridge-controller'; +import type { Quote, QuoteResponse, TxData } from '@metamask/bridge-controller'; import { toHex } from '@metamask/controller-utils'; import { TransactionStatus, @@ -90,16 +87,12 @@ export const getTxGasEstimates = async ( }; export const calculateGasFees = async ( - skipGasFields: boolean, messenger: BridgeStatusControllerMessenger, { chainId: _, gasLimit, ...trade }: TxData, networkClientId: string, chainId: Hex, txFee?: { maxFeePerGas: string; maxPriorityFeePerGas: string }, ) => { - if (skipGasFields) { - return {}; - } if (txFee) { return { ...txFee, gas: gasLimit?.toString() }; } @@ -315,13 +308,12 @@ export const waitForTxConfirmation = async ( }; export const toBatchTxParams = ( - skipGasFields: boolean, { chainId, gasLimit, ...trade }: TxData, - { - maxFeePerGas, - maxPriorityFeePerGas, - gas, - }: { maxFeePerGas?: string; maxPriorityFeePerGas?: string; gas?: string }, + gasFees?: { + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + gas?: string; + }, ): BatchTransactionParams => { const params = { ...trade, @@ -329,10 +321,11 @@ export const toBatchTxParams = ( to: trade.to as Hex, value: trade.value as Hex, }; - if (skipGasFields) { + if (!gasFees) { return params; } + const { maxFeePerGas, maxPriorityFeePerGas, gas } = gasFees; return { ...params, gas: toHex(gas ?? 0), @@ -341,121 +334,97 @@ export const toBatchTxParams = ( }; }; +const getGaslessParams = ({ + quote: { + feeData: { txFee }, + gasIncluded, + gasIncluded7702, + gasSponsored, + }, + isDelegatedAccount = false, +}: { + quote: Quote; + isDelegatedAccount: boolean; +}) => ({ + // Gas fields should be omitted only when gas is sponsored via 7702 + skipGasFields: gasIncluded7702, + disable7702: + // Enable 7702 batching when the quote includes gasless 7702 support, + gasIncluded7702 + ? false + : // or when the account is already delegated (to avoid the in-flight transaction limit for delegated accounts) + !isDelegatedAccount || + // For gasless transactions with STX/sendBundle we keep disabling 7702. + gasIncluded, + txFee: gasIncluded || gasIncluded7702 ? txFee : undefined, + isGasFeeIncluded: Boolean(gasIncluded7702), + isGasFeeSponsored: Boolean(gasSponsored), +}); + export const getAddTransactionBatchParams = async ({ messenger, - isBridgeTx, - approval, - resetApproval, - trade, - quoteResponse: { - quote: { - feeData: { txFee }, - gasIncluded, - gasIncluded7702, - gasSponsored, - }, - sentAmount, - toTokenAmount, - }, + tradeData, requireApproval = false, - isDelegatedAccount = false, + ...gasIncludedArgs }: { messenger: BridgeStatusControllerMessenger; - isBridgeTx: boolean; - trade: TxData; - quoteResponse: Omit & - Partial; - approval?: TxData; - resetApproval?: TxData; + tradeData: { + tx: TxData; + type: TransactionType; + assetsFiatValues?: { sending?: string; receiving?: string }; + }[]; requireApproval?: boolean; - isDelegatedAccount?: boolean; -}) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const isGasless = gasIncluded || gasIncluded7702; - const selectedAccount = getAccountByAddress(messenger, trade.from); +} & Parameters[0]): Promise< + Parameters[0] +> => { + const { + isGasFeeIncluded, + isGasFeeSponsored, + disable7702, + skipGasFields, + txFee, + } = getGaslessParams(gasIncludedArgs); + + const trade = tradeData[0]?.tx; + const selectedAccount = getAccountByAddress(messenger, trade?.from); if (!selectedAccount) { throw new Error( 'Failed to submit cross-chain swap batch transaction: unknown account in trade data', ); } + const hexChainId = formatChainIdToHex(trade.chainId); const networkClientId = getNetworkClientIdByChainId(messenger, hexChainId); - // Gas fields should be omitted only when gas is sponsored via 7702 - const skipGasFields = gasIncluded7702 === true; - // Enable 7702 batching when the quote includes gasless 7702 support, - // or when the account is already delegated (to avoid the in-flight - // transaction limit for delegated accounts) - let disable7702 = !skipGasFields && !isDelegatedAccount; - - // For gasless transactions with STX/sendBundle we keep disabling 7702. - if (gasIncluded && !gasIncluded7702) { - disable7702 = true; - } + const transactions: TransactionBatchSingleRequest[] = await Promise.all( + tradeData.map(async ({ type, assetsFiatValues, tx }) => { + const gasFees = skipGasFields + ? undefined + : await calculateGasFees( + messenger, + tx, + networkClientId, + hexChainId, + txFee, + ); + return { + type, + params: toBatchTxParams(tx, gasFees), + assetsFiatValues, + }; + }), + ).then((txs) => txs); - const transactions: TransactionBatchSingleRequest[] = []; - if (resetApproval) { - const gasFees = await calculateGasFees( - skipGasFields, - messenger, - resetApproval, - networkClientId, - hexChainId, - isGasless ? txFee : undefined, - ); - transactions.push({ - type: isBridgeTx - ? TransactionType.bridgeApproval - : TransactionType.swapApproval, - params: toBatchTxParams(skipGasFields, resetApproval, gasFees), - }); - } - if (approval) { - const gasFees = await calculateGasFees( - skipGasFields, - messenger, - approval, - networkClientId, - hexChainId, - isGasless ? txFee : undefined, - ); - transactions.push({ - type: isBridgeTx - ? TransactionType.bridgeApproval - : TransactionType.swapApproval, - params: toBatchTxParams(skipGasFields, approval, gasFees), - }); - } - const gasFees = await calculateGasFees( - skipGasFields, - messenger, - trade, - networkClientId, - hexChainId, - isGasless ? txFee : undefined, - ); - transactions.push({ - type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, - params: toBatchTxParams(skipGasFields, trade, gasFees), - assetsFiatValues: { - sending: sentAmount?.valueInCurrency?.toString(), - receiving: toTokenAmount?.valueInCurrency?.toString(), - }, - }); - const transactionParams: Parameters< - TransactionController['addTransactionBatch'] - >[0] = { + return { disable7702, - isGasFeeIncluded: Boolean(gasIncluded7702), - isGasFeeSponsored: Boolean(gasSponsored), + isGasFeeIncluded, + isGasFeeSponsored, networkClientId, requireApproval, origin: 'metamask', - from: trade.from as Hex, + from: selectedAccount.address as Hex, transactions, }; - - return transactionParams; }; export const findAndUpdateTransactionsInBatch = ({ From d709ae923aa19992a396ed1b9d66fa6d0a633613 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 9 Apr 2026 12:39:06 -0700 Subject: [PATCH 08/16] fix: unit tests --- eslint-suppressions.json | 7 +------ .../src/bridge-status-controller.ts | 2 +- .../src/strategy/batch-strategy.ts | 12 ++++++------ .../bridge-status-controller/src/strategy/types.ts | 2 +- .../bridge-status-controller/src/utils/history.ts | 7 ++----- 5 files changed, 11 insertions(+), 19 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cfcc1f3bae..bcdd7ab75b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -774,11 +774,6 @@ "count": 3 } }, - "packages/bridge-status-controller/src/utils/transaction.ts": { - "no-restricted-syntax": { - "count": 4 - } - }, "packages/chain-agnostic-permission/src/caip25Permission.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 11 @@ -2438,4 +2433,4 @@ "count": 10 } } -} +} \ No newline at end of file diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 90f7818c62..354ed00eed 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -477,7 +477,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { this.update((state) => { rekeyHistoryItemInState(state, oldKey, newKey, txMeta); diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts index bb59ccf924..4b5144d42f 100644 --- a/packages/bridge-status-controller/src/strategy/batch-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -29,20 +29,20 @@ export async function* submitBatchHandler( typeof getAddTransactionBatchParams >[0]['tradeData'] = []; + const approvalTxType = isBridgeTx + ? TransactionType.bridgeApproval + : TransactionType.swapApproval; + if (quoteResponse.resetApproval) { tradeData.push({ tx: quoteResponse.resetApproval, - type: isBridgeTx - ? TransactionType.bridgeApproval - : TransactionType.swapApproval, + type: approvalTxType, }); } if (quoteResponse.approval) { tradeData.push({ tx: quoteResponse.approval, - type: isBridgeTx - ? TransactionType.bridgeApproval - : TransactionType.swapApproval, + type: approvalTxType, }); } if (quoteResponse.trade) { diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index 949aa1ee44..182356cbfb 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -39,7 +39,7 @@ export type SubmitStepResult = /** Usually the txMeta.id of the `trade` transaction */ newKey: string; /** The {@link TransactionMeta} for the `trade` transaction after it has been submitted successfully */ - tradeMeta?: TransactionMeta; + tradeMeta: TransactionMeta; }; } | { diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index 8600fe1feb..ed1ad0c7bc 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -12,11 +12,8 @@ import type { const updateHistoryItem = ( oldHistoryItem: BridgeHistoryItem, - txMeta?: { id: string; hash?: string }, + txMeta: { id: string; hash?: string }, ): Partial => { - if (!txMeta) { - return {}; - } return { ...oldHistoryItem, txMetaId: txMeta.id, @@ -35,7 +32,7 @@ export const rekeyHistoryItemInState = ( state: BridgeStatusControllerState, oldKey: string, newKey: string, - txMeta?: { id: string; hash?: string }, + txMeta: { id: string; hash?: string }, ): boolean => { const historyItem = state.txHistory[oldKey]; if (!historyItem) { From ff4e75c5c5266bb249bb3b4b2a157e919457125d Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 9 Apr 2026 12:45:33 -0700 Subject: [PATCH 09/16] fix: lint error --- eslint-suppressions.json | 2 +- .../src/strategy/intent-strategy.ts | 46 +++++++++---------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 37c63b0e43..7e62ef9a21 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2393,4 +2393,4 @@ "count": 10 } } -} \ No newline at end of file +} diff --git a/packages/bridge-status-controller/src/strategy/intent-strategy.ts b/packages/bridge-status-controller/src/strategy/intent-strategy.ts index 000a094950..33722a8368 100644 --- a/packages/bridge-status-controller/src/strategy/intent-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/intent-strategy.ts @@ -165,31 +165,29 @@ export async function* submitIntentHandler( }); // Use synthetic transaction metadata + translated intent order status as the tradeMeta - if (syntheticTxMeta && orderStatus) { - yield { - type: 'setTradeMeta', - payload: { - ...syntheticTxMeta, - // Map intent order status to TransactionController status - status: mapIntentOrderStatusToTransactionStatus(orderStatus), - }, - }; - - // Update txHistory with synthetic txMeta and order id - yield { - type: 'addHistoryItem', - payload: { - // Use orderId as the history key for intent transactions - historyKey: orderUid, - bridgeTxMeta: { - id: syntheticTxMeta?.id, - }, - approvalTxId, - // Keep original txId for TransactionController updates - originalTransactionId: syntheticTxMeta?.id, + yield { + type: 'setTradeMeta', + payload: { + ...syntheticTxMeta, + // Map intent order status to TransactionController status + status: mapIntentOrderStatusToTransactionStatus(orderStatus), + }, + }; + + // Update txHistory with synthetic txMeta and order id + yield { + type: 'addHistoryItem', + payload: { + // Use orderId as the history key for intent transactions + historyKey: orderUid, + bridgeTxMeta: { + id: syntheticTxMeta?.id, }, - }; - } + approvalTxId, + // Keep original txId for TransactionController updates + originalTransactionId: syntheticTxMeta?.id, + }, + }; // Start polling using the orderId as the history key yield { From 5b79bb82c8e308661cfcdbe1d9d42d3126c881b8 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 9 Apr 2026 13:26:42 -0700 Subject: [PATCH 10/16] chore: changelog --- packages/bridge-status-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 86a13d9408..f2d4f16d40 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Refactor tx submission into strategies to reduce quote-specific branching in the controller, and to de-duplicate shared logic between `submitTx` and `submitIntent`. Each strategy yields payloads that the controller uses to update history, poll, and publish metrics ([#8257](https://github.com/MetaMask/core/pull/8257)) - Bump `@metamask/accounts-controller` from `^37.1.1` to `^37.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) - Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) - Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) From 67d46d8e6bba16532c3403f089b999544ab0bf1a Mon Sep 17 00:00:00 2001 From: micaelae Date: Wed, 6 May 2026 18:14:30 -0700 Subject: [PATCH 11/16] fix: lint --- .../bridge-status-controller.test.ts.snap | 4 +- .../src/bridge-status-controller.test.ts | 6 +- .../src/bridge-status-controller.ts | 192 ++++++++++-------- .../src/strategy/batch-strategy.ts | 7 +- .../src/strategy/evm-strategy.ts | 13 +- .../src/strategy/index.ts | 4 +- .../src/strategy/intent-strategy.ts | 10 +- .../src/strategy/non-evm-strategy.ts | 11 +- .../src/strategy/types.ts | 28 ++- .../bridge-status-controller/src/types.ts | 2 +- .../src/utils/history.test.ts | 2 +- 11 files changed, 146 insertions(+), 133 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 4c9a53c963..0b062848fb 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 @@ -14,7 +14,7 @@ exports[`BridgeStatusController constructor rehydrates the tx history state 1`] "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, - "location": undefined, + "location": "Main View", "originalTransactionId": "bridgeTxMetaId1", "pricingData": { "amountSent": "1.234", @@ -269,7 +269,7 @@ exports[`BridgeStatusController startPollingForBridgeTxStatus sets the inital tx "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, - "location": undefined, + "location": "Main View", "originalTransactionId": "bridgeTxMetaId1", "pricingData": { "amountSent": "1.234", 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 e03bf27b4e..76fb063089 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -17,6 +17,7 @@ import { FeatureId, getQuotesReceivedProperties, UnifiedSwapBridgeEventName, + MetaMetricsSwapsEventSource, } from '@metamask/bridge-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -332,6 +333,7 @@ const getMockStartPollingForBridgeTxStatusArgs = ({ initialDestAssetBalance: undefined, targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', isStxEnabled, + location: MetaMetricsSwapsEventSource.MainView, }); const MockTxHistory = { @@ -432,7 +434,7 @@ const MockTxHistory = { completionTime: undefined, attempts, featureId, - location: undefined, + location: MetaMetricsSwapsEventSource.MainView, }, }), getUnknown: ({ @@ -547,7 +549,7 @@ const MockTxHistory = { isStxEnabled: true, hasApprovalTx: false, attempts: undefined, - location: undefined, + location: MetaMetricsSwapsEventSource.MainView, }, }), }; diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 52b56dee9c..426a09ba77 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -35,13 +35,13 @@ import { MAX_ATTEMPTS, REFRESH_INTERVAL_MS, } from './constants'; -import executeSubmitFlow from './strategy'; +import executeSubmitStrategy from './strategy'; +import { SubmitStep } from './strategy/types'; import type { SubmitStrategyParams } from './strategy/types'; import type { BridgeStatusControllerState, StartPollingForBridgeTxStatusArgsSerialized, FetchFunction, - SolanaTransactionMeta, BridgeHistoryItem, } from './types'; import type { BridgeStatusControllerMessenger } from './types'; @@ -976,7 +976,7 @@ export class BridgeStatusController extends StaticIntervalPollingController, activeAbTests?: { key: string; value: string }[], tokenSecurityTypeDestination?: string | null, - ): Promise> => { + ): Promise => { stopPollingForQuotes( this.messenger, quoteResponse.featureId, @@ -1003,7 +1003,6 @@ export class BridgeStatusController extends StaticIntervalPollingController = { + messenger: this.messenger, + quoteResponse, + isStxEnabledOnClient: isStxEnabled, + isBridgeTx, + isDelegatedAccount, + selectedAccount, + requireApproval, + clientId: this.#clientId, + bridgeApiBaseUrl: this.#config.customBridgeApiBaseUrl, + addTransactionBatchFn: this.#addTransactionBatchFn, + fetchFn: this.#fetchFn, + traceFn: this.#trace, + }; + return await this.#trace( getTraceParams(quoteResponse, isStxEnabled), - async () => { - /** - * Check if the account is an EIP-7702 delegated account. - * Delegated accounts only allow 1 in-flight tx, so approve + swap - * must be batched into a single transaction - */ - const isDelegatedAccount = isNonEvmChainId( - quoteResponse.quote.srcChainId, - ) - ? false - : await checkIsDelegatedAccount( - this.messenger, - selectedAccount.address as Hex, - [formatChainIdToHex(quoteResponse.quote.srcChainId)], - ); - - const params: SubmitStrategyParams = { - quoteResponse, - isStxEnabledOnClient: isStxEnabled, - isDelegatedAccount, - messenger: this.messenger, - selectedAccount, - traceFn: this.#trace, - requireApproval, - isBridgeTx, - clientId: this.#clientId, - fetchFn: this.#fetchFn, - bridgeApiBaseUrl: this.#config.customBridgeApiBaseUrl, - addTransactionBatchFn: this.#addTransactionBatchFn, - }; - const steps = executeSubmitFlow(params); - - // Each submission strategy determines when to return values, which means these values can be returned in any order - for await (const { type, payload } of steps) { - if (type === 'rekeyHistoryItem') { - this.#rekeyHistoryItem( - payload.oldKey, - payload.newKey, - payload.tradeMeta, - ); - } - if (type === 'setTradeMeta') { - tradeTxMeta = payload; - } - - // Non-blocking steps - try { - if (type === 'addHistoryItem') { - this.#addTxToHistory(payload.historyKey, { - ...payload, - quoteResponse, - accountAddress: selectedAccount.address, - isStxEnabled, - startTime, - location, - abTests, - activeAbTests, - slippagePercentage: 0, // TODO include slippage provided by quote if using dynamic slippage, or slippage from quote request - tokenSecurityTypeDestination, - }); - } - if (type === 'startPolling') { - this.#startPollingForTxId(payload); - } - if (type === 'publishCompletedEvent') { - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Completed, - payload, - ); - } - } catch (error) { - console.error( - 'Failed to add to bridge history and start polling', - error, - ); - } - } - - return tradeTxMeta; - }, + async () => + await this.#executeSubmitStrategy(params, { + startTime, + location, + abTests, + activeAbTests, + tokenSecurityTypeDestination, + }), ); } catch (error) { if (!quoteResponse.featureId) { @@ -1156,7 +1110,7 @@ export class BridgeStatusController extends StaticIntervalPollingController, + sharedHistoryItemProperties: { + startTime: number; + location: MetaMetricsSwapsEventSource; + abTests?: Record; + activeAbTests?: { key: string; value: string }[]; + tokenSecurityTypeDestination?: string | null; + }, + ): Promise => { + let tradeTxMeta!: TransactionMeta; + + const steps = executeSubmitStrategy(params); + + // Each submission strategy determines when to return values, which means these values can be returned in any order + for await (const { type, payload } of steps) { + if (type === SubmitStep.RekeyHistoryItem) { + this.#rekeyHistoryItem( + payload.oldKey, + payload.newKey, + payload.tradeMeta, + ); + } + if (type === SubmitStep.SetTradeMeta) { + tradeTxMeta = payload; + } + + // If any of these steps fail, we should not block or fail the submission flow + try { + if (type === SubmitStep.AddHistoryItem) { + this.#addTxToHistory(payload.historyKey, { + ...payload, + ...sharedHistoryItemProperties, + quoteResponse: params.quoteResponse, + accountAddress: params.selectedAccount.address, + isStxEnabled: params.isStxEnabledOnClient, + slippagePercentage: 0, // TODO include slippage provided by quote if using dynamic slippage, or slippage from quote request + }); + } + if (type === SubmitStep.StartPolling) { + this.#startPollingForTxId(payload); + } + if (type === SubmitStep.PublishCompletedEvent) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Completed, + payload, + ); + } + } catch (error) { + console.error( + 'Failed to add to bridge history and start polling', + error, + ); + } + } + + return tradeTxMeta; + }; + readonly #trackPollingStatusUpdatedEvent = ( historyKey: string, pollingStatus: PollingStatus, @@ -1225,9 +1238,8 @@ export class BridgeStatusController extends StaticIntervalPollingController { - return await handleEvmApprovals(args); - }, + async () => await handleEvmApprovals(args), ); // Delay after approval @@ -118,7 +117,7 @@ export async function* submitEvmHandler( // Add pre-submission history keyed by actionId // This ensures we have quote data available if transaction fails during submission yield { - type: 'addHistoryItem', + type: SubmitStep.AddHistoryItem, payload: { historyKey: actionId, approvalTxId, @@ -146,7 +145,7 @@ export async function* submitEvmHandler( // Use the tradeMeta's id as history key yield { - type: 'rekeyHistoryItem', + type: SubmitStep.RekeyHistoryItem, payload: { oldKey: actionId, newKey: tradeMeta.id, @@ -155,7 +154,7 @@ export async function* submitEvmHandler( }; yield { - type: 'setTradeMeta', + type: SubmitStep.SetTradeMeta, payload: tradeMeta, }; } diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts index c6d73ca0bc..101fe60ab6 100644 --- a/packages/bridge-status-controller/src/strategy/index.ts +++ b/packages/bridge-status-controller/src/strategy/index.ts @@ -47,7 +47,7 @@ const validateParams = < * @returns An async generator that yields results from each step of the submit flow. The yielded * results are used to update the BridgeStatusController state and emit events. */ -const executeSubmitFlow = ( +const executeSubmitStrategy = ( params: SubmitStrategyParams, ): AsyncGenerator => { const { quoteResponse, isStxEnabledOnClient, isDelegatedAccount } = params; @@ -87,4 +87,4 @@ const executeSubmitFlow = ( return defaultSubmitHandler(params); }; -export default executeSubmitFlow; +export default executeSubmitStrategy; diff --git a/packages/bridge-status-controller/src/strategy/intent-strategy.ts b/packages/bridge-status-controller/src/strategy/intent-strategy.ts index 33722a8368..e1c554f9c1 100644 --- a/packages/bridge-status-controller/src/strategy/intent-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/intent-strategy.ts @@ -6,8 +6,6 @@ import { } from '@metamask/bridge-controller'; import { TransactionType } from '@metamask/transaction-controller'; -import { handleEvmApprovals } from './evm-strategy'; -import { SubmitStrategyParams, SubmitStepResult } from './types'; import { getJwt } from '../utils/authentication'; import { getIntentFromQuote, @@ -20,6 +18,8 @@ import { addSyntheticTransaction, waitForTxConfirmation, } from '../utils/transaction'; +import { handleEvmApprovals } from './evm-strategy'; +import { SubmitStrategyParams, SubmitStepResult, SubmitStep } from './types'; /** * Submits a synthetic EVM transaction to the TransactionController in order to display the intent order's @@ -166,7 +166,7 @@ export async function* submitIntentHandler( // Use synthetic transaction metadata + translated intent order status as the tradeMeta yield { - type: 'setTradeMeta', + type: SubmitStep.SetTradeMeta, payload: { ...syntheticTxMeta, // Map intent order status to TransactionController status @@ -176,7 +176,7 @@ export async function* submitIntentHandler( // Update txHistory with synthetic txMeta and order id yield { - type: 'addHistoryItem', + type: SubmitStep.AddHistoryItem, payload: { // Use orderId as the history key for intent transactions historyKey: orderUid, @@ -191,7 +191,7 @@ export async function* submitIntentHandler( // Start polling using the orderId as the history key yield { - type: 'startPolling', + type: SubmitStep.StartPolling, payload: orderUid, }; } diff --git a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts index dc6fce447f..36e5eede90 100644 --- a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts @@ -6,10 +6,11 @@ import type { TxData, } from '@metamask/bridge-controller'; -import type { SubmitStrategyParams, SubmitStepResult } from './types'; import { handleNonEvmTx } from '../utils/snaps'; import { getApprovalTraceParams } from '../utils/trace'; import { handleApprovalDelay } from '../utils/transaction'; +import { SubmitStep } from './types'; +import type { SubmitStrategyParams, SubmitStepResult } from './types'; /** * Submits the approval transaction for a non-EVM transaction if present @@ -78,12 +79,12 @@ export async function* submitNonEvmHandler( ); yield { - type: 'setTradeMeta', + type: SubmitStep.SetTradeMeta, payload: tradeMeta, }; yield { - type: 'addHistoryItem', + type: SubmitStep.AddHistoryItem, payload: { historyKey: tradeMeta.id, approvalTxId, @@ -95,13 +96,13 @@ export async function* submitNonEvmHandler( }; yield { - type: 'startPolling', + type: SubmitStep.StartPolling, payload: tradeMeta.id, }; if (!isTronChainId(quoteResponse.quote.srcChainId) && !isBridgeTx) { yield { - type: 'publishCompletedEvent', + type: SubmitStep.PublishCompletedEvent, payload: tradeMeta.id, }; } diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index 182356cbfb..7f31ce060f 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -18,12 +18,20 @@ import type { StartPollingForBridgeTxStatusArgs, } from '../types'; +export enum SubmitStep { + AddHistoryItem = 'addHistoryItem', + RekeyHistoryItem = 'rekeyHistoryItem', + StartPolling = 'startPolling', + PublishCompletedEvent = 'publishCompletedEvent', + SetTradeMeta = 'setTradeMeta', +} + /** * Any possible result returned by steps in a submission strategy. These can be returned in any order. */ export type SubmitStepResult = | { - type: 'addHistoryItem'; + type: SubmitStep.AddHistoryItem; payload: Pick< StartPollingForBridgeTxStatusArgs, 'approvalTxId' | 'bridgeTxMeta' | 'originalTransactionId' | 'actionId' @@ -32,7 +40,7 @@ export type SubmitStepResult = }; } | { - type: 'rekeyHistoryItem'; + type: SubmitStep.RekeyHistoryItem; payload: { /** Usually the actionId of the preceeding `approval` transaction */ oldKey: string; @@ -43,17 +51,17 @@ export type SubmitStepResult = }; } | { - type: 'startPolling'; + type: SubmitStep.StartPolling; /** The `txHistory` key of the transaction to start polling for */ payload: string; } | { - type: 'publishCompletedEvent'; + type: SubmitStep.PublishCompletedEvent; /** The `txHistory` key of the transaction that has been submitted successfully */ payload: string; } | { - type: 'setTradeMeta'; + type: SubmitStep.SetTradeMeta; /** The {@link TransactionMeta} for the transaction that has been submitted successfully */ payload: TransactionMeta; }; @@ -76,13 +84,3 @@ export type SubmitStrategyParams = { clientId: BridgeClientId; bridgeApiBaseUrl: string; }; - -/** - * A strategy for submitting a transaction and/or intent - */ -export type SubmitStrategy = { - matchesFlow: (params: SubmitStrategyParams) => boolean; - execute: ( - params: SubmitStrategyParams, - ) => AsyncGenerator; -}; diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 05222f513d..6b01432a4b 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -239,7 +239,7 @@ export type StartPollingForBridgeTxStatusArgs = { targetContractAddress?: BridgeHistoryItem['targetContractAddress']; approvalTxId?: BridgeHistoryItem['approvalTxId']; isStxEnabled?: BridgeHistoryItem['isStxEnabled']; - location?: BridgeHistoryItem['location']; + location: MetaMetricsSwapsEventSource; // Legacy field for `ab_tests` metrics payload. abTests?: BridgeHistoryItem['abTests']; // New field for `active_ab_tests` metrics payload. diff --git a/packages/bridge-status-controller/src/utils/history.test.ts b/packages/bridge-status-controller/src/utils/history.test.ts index b6b10ac975..04f522c706 100644 --- a/packages/bridge-status-controller/src/utils/history.test.ts +++ b/packages/bridge-status-controller/src/utils/history.test.ts @@ -128,7 +128,7 @@ describe('History Utils', () => { } as unknown as StartPollingForBridgeTxStatusArgsSerialized; it('omits tokenSecurityTypeDestination when not provided', () => { - const txHistoryItem = getInitialHistoryItem(baseArgs); + const txHistoryItem = getInitialHistoryItem(baseArgs); expect( Object.prototype.hasOwnProperty.call( txHistoryItem, From 7db8bc0dcf05aa84e9a30afda11580a659417732 Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 7 May 2026 10:40:54 -0700 Subject: [PATCH 12/16] fix: trace intent approvals --- .../src/strategy/evm-strategy.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/bridge-status-controller/src/strategy/evm-strategy.ts b/packages/bridge-status-controller/src/strategy/evm-strategy.ts index d8892efc96..57e503a4b2 100644 --- a/packages/bridge-status-controller/src/strategy/evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/evm-strategy.ts @@ -52,7 +52,7 @@ const handleSingleTx = async ( * * @returns The approvalTxId of the approval transaction */ -export const handleEvmApprovals = async (args: SubmitStrategyParams) => { +const approve = async (args: SubmitStrategyParams) => { const { quoteResponse, isBridgeTx } = args; const { approval, resetApproval } = quoteResponse; if (!approval || !isEvmTxData(approval)) { @@ -77,6 +77,12 @@ export const handleEvmApprovals = async (args: SubmitStrategyParams) => { } }; +export const handleEvmApprovals = async (args: SubmitStrategyParams) => + await args.traceFn( + getApprovalTraceParams(args.quoteResponse, args.isStxEnabledOnClient), + async () => await approve(args), + ); + /** * Sequentially submits EVM resetApproval, approval and trade transactions through the TransactionController. * @@ -86,19 +92,10 @@ export const handleEvmApprovals = async (args: SubmitStrategyParams) => { export async function* submitEvmHandler( args: SubmitStrategyParams, ): AsyncGenerator { - const { - quoteResponse, - traceFn, - requireApproval, - isStxEnabledOnClient, - isBridgeTx, - } = args; + const { quoteResponse, requireApproval, isBridgeTx } = args; // Submit resetApproval and approval transactions if present - const approvalTxId = await traceFn( - getApprovalTraceParams(quoteResponse, isStxEnabledOnClient), - async () => await handleEvmApprovals(args), - ); + const approvalTxId = await handleEvmApprovals(args); // Delay after approval if (approvalTxId) { From f6a555f4605985efc1e29cf40d75c4d82e91cadb Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 7 May 2026 15:11:49 -0700 Subject: [PATCH 13/16] chore: test coverage --- .../src/bridge-status-controller.test.ts | 76 ++++++++++ .../src/bridge-status-controller.ts | 133 ++++++++++-------- .../src/strategy/evm-strategy.ts | 9 +- .../src/strategy/index.ts | 6 +- .../src/strategy/intent-strategy.ts | 2 +- .../src/strategy/types.ts | 2 +- .../src/utils/metrics.ts | 6 +- .../src/utils/trace.ts | 8 +- 8 files changed, 161 insertions(+), 81 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 76fb063089..e5e4ba8fe0 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -58,6 +58,7 @@ import type { import * as bridgeStatusUtils from './utils/bridge-status'; import * as historyUtils from './utils/history'; import * as transactionUtils from './utils/transaction'; +import * as metricsUtils from './utils/metrics'; type AllBridgeStatusControllerActions = MessengerActions; @@ -5102,6 +5103,81 @@ describe('BridgeStatusController', () => { expect(messengerCallSpy.mock.lastCall).toMatchSnapshot(); }); + it('should use txMeta properties if history item does not exist', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + + const 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', + }; + const getEVMTxPropertiesFromTransactionMetaSpy = jest + .spyOn(metricsUtils, 'getEVMTxPropertiesFromTransactionMeta') + .mockImplementationOnce(() => { + bridgeStatusController.wipeBridgeStatus({ + address: 'otherAccount', + ignoreNetwork: true, + }); + return metricsUtils.getEVMTxPropertiesFromTransactionMeta( + transactionMeta, + ); + }); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta, + }, + ); + + expect(getEVMTxPropertiesFromTransactionMetaSpy).toHaveBeenCalledTimes( + 2, + ); + expect(bridgeStatusController.state.txHistory).toStrictEqual({}); + expect(messengerCallSpy.mock.lastCall).toMatchInlineSnapshot(` + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "error_message": "Transaction failed. tx-error", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Main View", + "price_impact": 0, + "provider": "", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 0, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "source_transaction": "FAILED", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:42161/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "", + "token_symbol_source": "", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ] + `); + }); + it('should include ab_tests and active_ab_tests from history in tracked event properties', () => { const abTestsTxMetaId = 'bridgeTxMetaIdAbTests'; mockMessenger.call( diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 426a09ba77..dc3c4f6cd4 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -953,6 +953,74 @@ export class BridgeStatusController extends StaticIntervalPollingController, + sharedHistoryItemProperties: { + startTime: number; + location: MetaMetricsSwapsEventSource; + abTests?: Record; + activeAbTests?: { key: string; value: string }[]; + tokenSecurityTypeDestination?: string | null; + }, + ): Promise => { + let tradeTxMeta!: TransactionMeta; + + const steps = executeSubmitStrategy(params); + + // Each submission strategy determines when to execute step, which means these actions can happen in any order + for await (const { type, payload } of steps) { + try { + switch (type) { + case SubmitStep.RekeyHistoryItem: + this.#rekeyHistoryItem( + payload.oldKey, + payload.newKey, + payload.tradeMeta, + ); + break; + + case SubmitStep.SetTradeMeta: + tradeTxMeta = payload; + break; + + case SubmitStep.AddHistoryItem: + this.#addTxToHistory(payload.historyKey, { + ...payload, + ...sharedHistoryItemProperties, + quoteResponse: params.quoteResponse, + accountAddress: params.selectedAccount.address, + isStxEnabled: params.isStxEnabled, + slippagePercentage: 0, // TODO include slippage provided by quote if using dynamic slippage, or slippage from quote request + }); + break; + + case SubmitStep.StartPolling: + this.#startPollingForTxId(payload); + break; + + case SubmitStep.PublishCompletedEvent: + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Completed, + payload, + ); + break; + + /* c8 ignore start */ + default: + throw new Error(`Unknown submit step type: ${String(type)}`); + /* c8 ignore end */ + } + } catch (error) { + console.error( + 'Failed to add to bridge history and start polling.', + error, + ); + } + } + + return tradeTxMeta; + }; + /** * Submits a cross-chain swap transaction * @@ -1035,10 +1103,10 @@ export class BridgeStatusController extends StaticIntervalPollingController = { + const strategyParams: SubmitStrategyParams = { messenger: this.messenger, quoteResponse, - isStxEnabledOnClient: isStxEnabled, + isStxEnabled, isBridgeTx, isDelegatedAccount, selectedAccount, @@ -1053,7 +1121,7 @@ export class BridgeStatusController extends StaticIntervalPollingController - await this.#executeSubmitStrategy(params, { + await this.#executeSubmitStrategy(strategyParams, { startTime, location, abTests, @@ -1126,65 +1194,6 @@ export class BridgeStatusController extends StaticIntervalPollingController, - sharedHistoryItemProperties: { - startTime: number; - location: MetaMetricsSwapsEventSource; - abTests?: Record; - activeAbTests?: { key: string; value: string }[]; - tokenSecurityTypeDestination?: string | null; - }, - ): Promise => { - let tradeTxMeta!: TransactionMeta; - - const steps = executeSubmitStrategy(params); - - // Each submission strategy determines when to return values, which means these values can be returned in any order - for await (const { type, payload } of steps) { - if (type === SubmitStep.RekeyHistoryItem) { - this.#rekeyHistoryItem( - payload.oldKey, - payload.newKey, - payload.tradeMeta, - ); - } - if (type === SubmitStep.SetTradeMeta) { - tradeTxMeta = payload; - } - - // If any of these steps fail, we should not block or fail the submission flow - try { - if (type === SubmitStep.AddHistoryItem) { - this.#addTxToHistory(payload.historyKey, { - ...payload, - ...sharedHistoryItemProperties, - quoteResponse: params.quoteResponse, - accountAddress: params.selectedAccount.address, - isStxEnabled: params.isStxEnabledOnClient, - slippagePercentage: 0, // TODO include slippage provided by quote if using dynamic slippage, or slippage from quote request - }); - } - if (type === SubmitStep.StartPolling) { - this.#startPollingForTxId(payload); - } - if (type === SubmitStep.PublishCompletedEvent) { - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Completed, - payload, - ); - } - } catch (error) { - console.error( - 'Failed to add to bridge history and start polling', - error, - ); - } - } - - return tradeTxMeta; - }; - readonly #trackPollingStatusUpdatedEvent = ( historyKey: string, pollingStatus: PollingStatus, diff --git a/packages/bridge-status-controller/src/strategy/evm-strategy.ts b/packages/bridge-status-controller/src/strategy/evm-strategy.ts index 57e503a4b2..6d06c629a9 100644 --- a/packages/bridge-status-controller/src/strategy/evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/evm-strategy.ts @@ -31,8 +31,8 @@ const handleSingleTx = async ( transactionType: TransactionType, trade: TxData, submitParams: Partial[0]> = {}, -) => { - const approvalTxMeta = await submitEvmTransaction({ +) => + await submitEvmTransaction({ messenger, trade, transactionType, @@ -40,9 +40,6 @@ const handleSingleTx = async ( ...submitParams, }); - return approvalTxMeta; -}; - /** * Submits the approval and resetApproval transactions through the TransactionController. * If there is a resetApproval, it will be submitted first. @@ -79,7 +76,7 @@ const approve = async (args: SubmitStrategyParams) => { export const handleEvmApprovals = async (args: SubmitStrategyParams) => await args.traceFn( - getApprovalTraceParams(args.quoteResponse, args.isStxEnabledOnClient), + getApprovalTraceParams(args.quoteResponse, args.isStxEnabled), async () => await approve(args), ); diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts index 101fe60ab6..93083e4d8a 100644 --- a/packages/bridge-status-controller/src/strategy/index.ts +++ b/packages/bridge-status-controller/src/strategy/index.ts @@ -50,7 +50,7 @@ const validateParams = < const executeSubmitStrategy = ( params: SubmitStrategyParams, ): AsyncGenerator => { - const { quoteResponse, isStxEnabledOnClient, isDelegatedAccount } = params; + const { quoteResponse, isStxEnabled, isDelegatedAccount } = params; // Non-EVM transactions if (isNonEvmChainId(quoteResponse.quote.srcChainId)) { @@ -76,9 +76,7 @@ const executeSubmitStrategy = ( // Batched transactions const shouldBatchTxs = - isStxEnabledOnClient || - quoteResponse.quote.gasIncluded7702 || - isDelegatedAccount; + isStxEnabled || quoteResponse.quote.gasIncluded7702 || isDelegatedAccount; if (shouldBatchTxs) { return submitBatchHandler(params); } diff --git a/packages/bridge-status-controller/src/strategy/intent-strategy.ts b/packages/bridge-status-controller/src/strategy/intent-strategy.ts index e1c554f9c1..8183afcf28 100644 --- a/packages/bridge-status-controller/src/strategy/intent-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/intent-strategy.ts @@ -161,7 +161,7 @@ export async function* submitIntentHandler( const syntheticTxMeta = await handleSyntheticTx(orderUid, { ...args, requireApproval: false, - isStxEnabledOnClient: false, + isStxEnabled: false, }); // Use synthetic transaction metadata + translated intent order status as the tradeMeta diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index 7f31ce060f..8687a73ddc 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -73,7 +73,7 @@ export type SubmitStrategyParams = { addTransactionBatchFn: TransactionController['addTransactionBatch']; isBridgeTx: boolean; isDelegatedAccount: boolean; - isStxEnabledOnClient: boolean; + isStxEnabled: boolean; messenger: BridgeStatusControllerMessenger; quoteResponse: QuoteResponse & QuoteMetadata; requireApproval: boolean; diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 5f536df164..3f8c6fd6f7 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -182,7 +182,7 @@ export const getPriceImpactFromQuote = ( * The quote is used to populate event properties before confirmation * * @param quoteResponse - The quote response - * @param isStxEnabledOnClient - Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension + * @param isStxEnabled - Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension * @param accountHardwareType - The hardware wallet type used to submit the tx, or null if not a hardware wallet * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) * @param abTests - Legacy A/B test context for `ab_tests` (backward compatibility) @@ -192,7 +192,7 @@ export const getPriceImpactFromQuote = ( */ export const getPreConfirmationPropertiesFromQuote = ( quoteResponse: QuoteResponse & Partial, - isStxEnabledOnClient: boolean, + isStxEnabled: boolean, accountHardwareType: AccountHardwareType, location?: MetaMetricsSwapsEventSource, abTests?: Record, @@ -217,7 +217,7 @@ export const getPreConfirmationPropertiesFromQuote = ( quoteResponse.quote.destChainId, ), usd_amount_source: Number(quoteResponse.sentAmount?.usd ?? 0), - stx_enabled: isStxEnabledOnClient, + stx_enabled: isStxEnabled, action_type: MetricsActionType.SWAPBRIDGE_V1, custom_slippage: false, // TODO detect whether the user changed the default slippage location, diff --git a/packages/bridge-status-controller/src/utils/trace.ts b/packages/bridge-status-controller/src/utils/trace.ts index 1bc7587819..72098788d1 100644 --- a/packages/bridge-status-controller/src/utils/trace.ts +++ b/packages/bridge-status-controller/src/utils/trace.ts @@ -9,7 +9,7 @@ import { TraceName } from '../constants'; export const getTraceParams = ( quoteResponse: QuoteResponse, - isStxEnabledOnClient: boolean, + isStxEnabled: boolean, ) => { return { name: isCrossChain( @@ -20,14 +20,14 @@ export const getTraceParams = ( : TraceName.SwapTransactionCompleted, data: { srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), - stxEnabled: isStxEnabledOnClient, + stxEnabled: isStxEnabled, }, }; }; export const getApprovalTraceParams = ( quoteResponse: QuoteResponse, - isStxEnabledOnClient: boolean, + isStxEnabled: boolean, ) => { return { name: isCrossChain( @@ -38,7 +38,7 @@ export const getApprovalTraceParams = ( : TraceName.SwapTransactionApprovalCompleted, data: { srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), - stxEnabled: isStxEnabledOnClient, + stxEnabled: isStxEnabled, }, }; }; From 68d5ab3c9f76a004f31cfbce02d5eecd94355551 Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 7 May 2026 17:45:00 -0700 Subject: [PATCH 14/16] refactor: featureId --- .../src/bridge-status-controller.ts | 109 ++++++++---------- 1 file changed, 51 insertions(+), 58 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index dc3c4f6cd4..e3970f2d2d 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -4,6 +4,7 @@ import type { RequiredEventContextFromClient, QuoteResponse, Trade, + FeatureId, } from '@metamask/bridge-controller'; import { isNonEvmChainId, @@ -1045,11 +1046,10 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - stopPollingForQuotes( - this.messenger, - quoteResponse.featureId, - quotesReceivedContext, - ); + const { featureId, quote } = quoteResponse; + const startTime = Date.now(); + + stopPollingForQuotes(this.messenger, featureId, quotesReceivedContext); const selectedAccount = getAccountByAddress(this.messenger, accountAddress); if (!selectedAccount) { @@ -1065,11 +1065,7 @@ export class BridgeStatusController extends StaticIntervalPollingController = { @@ -1130,16 +1126,15 @@ 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, - ), - ); - } + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.PollingStatusUpdated, + historyKey, + getPollingStatusUpdatedProperties( + this.messenger, + pollingStatus, + historyItem, + ), + ); }; /** @@ -1219,6 +1212,8 @@ export class BridgeStatusController extends StaticIntervalPollingController[EventName], + featureIdOverride?: FeatureId, ): void => { + const historyItem: BridgeHistoryItem | undefined = txMetaId + ? this.state.txHistory[txMetaId] + : undefined; + const featureId = featureIdOverride ?? historyItem?.featureId; + + const shouldSkipMetrics = + // Skip tracking all other events when featureId is set (i.e. PERPS) + featureId && + // Always publish StatusValidationFailed event, regardless of featureId + eventName !== UnifiedSwapBridgeEventName.StatusValidationFailed; + if (shouldSkipMetrics) { + return; + } + // Legacy/new metrics fields are intentionally kept independent during migration. const historyAbTests = txMetaId ? this.state.txHistory?.[txMetaId]?.abTests @@ -1265,18 +1275,6 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Fri, 8 May 2026 12:32:59 -0700 Subject: [PATCH 15/16] chore: update types --- .../src/bridge-status-controller.ts | 10 +++++----- .../src/strategy/batch-strategy.ts | 2 +- .../src/strategy/evm-strategy.ts | 8 +++++--- .../src/strategy/intent-strategy.ts | 12 +++++++---- .../src/strategy/non-evm-strategy.ts | 10 +++++++--- .../src/strategy/types.ts | 20 ++++++++++++------- 6 files changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index e3970f2d2d..d8ac782b40 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -974,14 +974,14 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Tue, 19 May 2026 08:44:19 -0700 Subject: [PATCH 16/16] fix: lint --- .../src/bridge-status-controller.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e5e4ba8fe0..384aeb978a 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -57,8 +57,8 @@ import type { } from './types'; import * as bridgeStatusUtils from './utils/bridge-status'; import * as historyUtils from './utils/history'; -import * as transactionUtils from './utils/transaction'; import * as metricsUtils from './utils/metrics'; +import * as transactionUtils from './utils/transaction'; type AllBridgeStatusControllerActions = MessengerActions;