From bb4d7b531c1496a7c68437b983a1f5a84f972860 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Wed, 18 Mar 2026 17:31:40 -0700 Subject: [PATCH 1/3] feat: implement new submission flows --- .../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 +++++++++ 7 files changed, 666 insertions(+) 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/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 7f70c6afd56..a66647a9ca5 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -947,6 +947,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 00000000000..29db022c6ba --- /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 00000000000..232ce4c4b75 --- /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 00000000000..c6176a4fffe --- /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 00000000000..62d03cde10e --- /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 00000000000..41d93cfa736 --- /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 00000000000..5102a18bb8b --- /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 55b4e34bad625b7e788fb7e62f065001a1dfcf84 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Wed, 18 Mar 2026 17:32:03 -0700 Subject: [PATCH 2/3] feat: use submission flows in controller --- .../bridge-status-controller.intent.test.ts | 2 - .../src/bridge-status-controller.ts | 598 ++++-------------- 2 files changed, 118 insertions(+), 482 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 904aee21324..3096d91556e 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 a66647a9ca5..f6d9a0e3d0c 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'; @@ -493,9 +470,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; }); }; @@ -862,80 +839,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 * @@ -970,7 +873,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); - - // Submit non-EVM tx (Solana, BTC, Tron) - if (isNonEvmChainId(quoteResponse.quote.srcChainId)) { - // Handle non-EVM approval if present (e.g., Tron token approvals) - if (quoteResponse.approval && isTronTrade(quoteResponse.approval)) { - const approvalTxMeta = await this.#trace( - getApprovalTraceParams(quoteResponse, false), - async () => { - try { - return quoteResponse.approval && - isTronTrade(quoteResponse.approval) - ? await handleNonEvmTx( - this.messenger, - quoteResponse.approval, - quoteResponse, - selectedAccount, - ) - : /* c8 ignore start */ - undefined; - /* c8 ignore end */ - } catch (error) { - !quoteResponse.featureId && - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Failed, - undefined, - { - error_message: (error as Error)?.message, - ...preConfirmationProperties, - }, - ); - throw error; - } - }, - ); - - approvalTxId = approvalTxMeta?.id; - - // Add delay after approval similar to EVM flow - await handleApprovalDelay(quoteResponse.quote.srcChainId); - } + let tradeTxMeta: TransactionMeta; + let publishFailedEvent = false; - txMeta = await this.#trace( - getTraceParams(quoteResponse, false), - async () => { - try { - 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 handleNonEvmTx( - this.messenger, - quoteResponse.trade, - quoteResponse, - selectedAccount, - ); - } catch (error) { - !quoteResponse.featureId && - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Failed, - undefined, - { - error_message: (error as Error)?.message, - ...preConfirmationProperties, - }, - ); - throw error; - } - }, - ); - } 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( + try { + return 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', - ); - } - // 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, - quoteResponse, - requireApproval, - isDelegatedAccount, - }); - - approvalTxId = approvalMeta?.id; - return tradeMeta; - } - // Set approval time and id if an approval tx is needed - const approvalTxMeta = await this.#handleApprovalTx( - quoteResponse, - isBridgeTx, + /** + * 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, - quoteResponse.approval && isEvmTxData(quoteResponse.approval) - ? quoteResponse.approval - : undefined, - quoteResponse.resetApproval, - requireApproval, - ); - - approvalTxId = approvalTxMeta?.id; - - await handleMobileHardwareWalletDelay(requireApproval); - - // Generate actionId for pre-submission history (non-batch EVM only) - const actionId = generateActionId().toString(); + ) + ? false + : await checkIsDelegatedAccount( + this.messenger, + selectedAccount.address as Hex, + [formatChainIdToHex(quoteResponse.quote.srcChainId)], + ); - // Add pre-submission history keyed by actionId - // This ensures we have quote data available if transaction fails during submission - this.#addTxToHistory({ - accountAddress: selectedAccount.address, + const params: SubmitStrategyParams = { 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({ + isStxEnabledOnClient, + isDelegatedAccount, messenger: this.messenger, - transactionType: isBridgeTx - ? TransactionType.bridge - : TransactionType.swap, - trade: quoteResponse.trade, + selectedAccount, + traceFn: this.#trace, requireApproval, - txFee: quoteResponse.quote.gasIncluded - ? quoteResponse.quote.feeData.txFee - : undefined, - actionId, - }); + 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 === 'publishFailedEvent') { + publishFailedEvent = payload; + } + if (type === 'rekeyHistoryItem') { + this.#rekeyHistoryItem(payload.actionId, payload.tradeMeta); + } + if (type === 'setTradeMeta') { + tradeTxMeta = payload; + } - // On success, rekey from actionId to txMeta.id and update srcTxHash - this.#rekeyHistoryItem(actionId, tradeTxMeta); + // Non-blocking steps + try { + if (type === 'addHistoryItem') { + this.#addTxToHistory({ + ...payload, + quoteResponse, + 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 + }); + } + 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; }, ); - } - - try { - // For non-batch EVM transactions, history was already added/rekeyed above - // Only add history here for non-EVM and batch EVM transactions - const isNonBatchEvm = - !isNonEvmChainId(quoteResponse.quote.srcChainId) && - !isStxEnabledOnClient && - !quoteResponse.quote.gasIncluded7702 && - !isDelegatedAccount; - - if (!isNonBatchEvm) { - // Add swap or bridge tx to history - this.#addTxToHistory({ - accountAddress: selectedAccount.address, - bridgeTxMeta: txMeta, // Only the id field is used by the BridgeStatusController - quoteResponse, - slippagePercentage: 0, // TODO include slippage provided by quote if using dynamic slippage, or slippage from quote request - isStxEnabled: isStxEnabledOnClient, - startTime, - approvalTxId, - location, - abTests, - activeAbTests, - }); - } - - if (isNonEvmChainId(quoteResponse.quote.srcChainId)) { - // Start polling for bridge tx status - this.#startPollingForTxId(txMeta.id); - // Track non-EVM Swap completed event - if (!(isBridgeTx || isTronTx)) { - this.#trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.Completed, - txMeta.id, - ); - } + } catch (error) { + if (!quoteResponse.featureId && publishFailedEvent) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Failed, + undefined, + { + error_message: (error as Error)?.message, + ...preConfirmationProperties, + }, + ); } - } catch { - // Ignore errors here, we don't want to crash the app if this fails and tx submission succeeds + throw error; } - return txMeta; }; /** @@ -1231,6 +1013,8 @@ 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, - ); - - // TODO reuse addTransaction util - 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, - { - // TODO use requireApproval logic in submitTx - 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; - } }; /** From 71783df5ce4aa36f3f4cd485b33eefe5cd8479e6 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 19 Mar 2026 16:27:55 -0700 Subject: [PATCH 3/3] chore: bug comment --- .../bridge-status-controller/src/strategy/non-evm-strategy.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 41d93cfa736..703b1b7920c 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,14 @@ export async function* submitNonEvmHandler( args: SubmitStrategyParams, ): AsyncGenerator { const { quoteResponse, isBridgeTx } = args; + // There is an existing bug in which the Failed event is not published for any other submission flows + // Until the transaction has been added to the TransactionController state + // TODO remove this once the bug is fixed yield { type: 'publishFailedEvent', payload: true, }; + if ( !( isTronTrade(quoteResponse.trade) ||