diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 5f5e065093..e9f3dc3fb3 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^8.0.0` to `^8.0.1` ([#8874](https://github.com/MetaMask/core/pull/8874)) +### Added + +- Add `gasIncluded` and `gasIncluded7702` to `BatchSellTradesResponseSchema` + +### Removed + +- **BREAKING**: Deprecate `BridgeUserAction` and `BridgeBackgroundAction` enums + ## [73.0.1] ### Changed diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 52fb833ab7..e1b3b447e8 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -83,8 +83,6 @@ export { SortOrder, ChainId, RequestStatus, - BridgeUserAction, - BridgeBackgroundAction, type TokenFeature, type QuoteStreamCompleteData, type BridgeControllerGetStateAction, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index c9f96fad49..cd5e52ad93 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -361,27 +361,6 @@ export enum RequestStatus { ERROR = 2, } -/** - * @deprecated Use the separate method action types (e.g., - * `BridgeControllerFetchQuotesAction`) instead. - */ -export enum BridgeUserAction { - SELECT_DEST_NETWORK = 'selectDestNetwork', - UPDATE_QUOTE_PARAMS = 'updateBridgeQuoteRequestParams', -} - -/** - * @deprecated Use the separate method action types (e.g., - * `BridgeControllerFetchQuotesAction`) instead. - */ -export enum BridgeBackgroundAction { - SET_CHAIN_INTERVAL_LENGTH = 'setChainIntervalLength', - RESET_STATE = 'resetState', - TRACK_METAMETRICS_EVENT = 'trackUnifiedSwapBridgeEvent', - STOP_POLLING_FOR_QUOTES = 'stopPollingForQuotes', - FETCH_QUOTES = 'fetchQuotes', -} - export type BridgeControllerState = { quoteRequest: Partial[]; quotes: (QuoteResponse & L1GasFees & NonEvmFees)[]; diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 33d6a5edd9..06030fb11e 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -547,21 +547,24 @@ export const SimulatedGasFeeLimitsSchema = type({ maxPriorityFeePerGas: HexStringSchema, }); -export const BatchSellTradesResponseSchema = type({ - transactions: array( - intersection([ - TxDataSchema, - SimulatedGasFeeLimitsSchema, - type({ type: enums(Object.values(BatchSellTransactionType)) }), - ]), - ), - fee: optional( - type({ - asset: BridgeAssetSchema, - amount: NumberStringSchema, - }), - ), -}); +export const BatchSellTradesResponseSchema = intersection([ + type({ + transactions: array( + intersection([ + TxDataSchema, + SimulatedGasFeeLimitsSchema, + type({ type: enums(Object.values(BatchSellTransactionType)) }), + ]), + ), + fee: optional( + type({ + asset: BridgeAssetSchema, + amount: NumberStringSchema, + }), + ), + }), + GaslessPropertiesSchema, +]); export const validateBatchSellTradesResponse = ( data: unknown, diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index c5f143326e..3e40a4a05f 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,8 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Implement `submitBatchSell` method to submit BatchSell transactions to the TransactionController via STX or 7702. This requires clients to add `BridgeControllerGetStateAction` as an allowed action ([#8775](https://github.com/MetaMask/core/pull/8775)) +- Wire up post-submission BatchSell history ([#8775](https://github.com/MetaMask/core/pull/8775)) + - Create a history item for each STX trade in a batch, with the same batchId (key by `txMeta.id`) + - Create a history item for each trade submitted through a 7702 batch (key by `quoteId`). These won't have a reference to the batchId, and will only include quote and fee data + - Create a history item for the 7702 batch's delegation tx (key by `txMeta.id`). BatchSell delegation transactions include a list of `quoteIds` to associate the corresponding BatchSell trades with the delegation tx + - Expose `getHistoryItemsForTxHash` util that returns history items matching either a delegation tx hash or an STX hash + - Expose `isBatchSellHistoryItem` util that returns whether a history item is a BatchSell operation + ### Changed +- Update controller and submit strategies to support an array of quotes instead of a single one ([#8775](https://github.com/MetaMask/core/pull/8775)) - 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/bridge-controller` from `^72.0.4` to `^73.0.1` ([#8850](https://github.com/MetaMask/core/pull/8850), [#8866](https://github.com/MetaMask/core/pull/8866)) - Refactor batch transaction utils to handle multiple quote requests within a batch (for BatchSell integration) ([#8886](https://github.com/MetaMask/core/pull/8886)) diff --git a/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts b/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts index 1e885f0d41..d6a4aaf648 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts @@ -40,6 +40,11 @@ export type BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction = { handler: BridgeStatusController['getBridgeHistoryItemByTxMetaId']; }; +export type BridgeStatusControllerSubmitBatchSellAction = { + type: `BridgeStatusController:submitBatchSell`; + handler: BridgeStatusController['submitBatchSell']; +}; + /** * Union of all BridgeStatusController action types. */ @@ -50,4 +55,5 @@ export type BridgeStatusControllerMethodActions = | BridgeStatusControllerSubmitTxAction | BridgeStatusControllerSubmitIntentAction | BridgeStatusControllerRestartPollingForFailedAttemptsAction - | BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction; + | BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction + | BridgeStatusControllerSubmitBatchSellAction; diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index c705b66c67..f3145ff941 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -5,6 +5,7 @@ import type { QuoteResponse, Trade, FeatureId, + BatchSellTradesResponse, } from '@metamask/bridge-controller'; import { isNonEvmChainId, @@ -49,7 +50,11 @@ import type { BridgeStatusControllerMessenger } from './types'; import { BridgeClientId } from './types'; import { getAccountByAddress } from './utils/accounts'; import { getJwt } from './utils/authentication'; -import { stopPollingForQuotes, trackMetricsEvent } from './utils/bridge'; +import { + getBatchSellTrades, + stopPollingForQuotes, + trackMetricsEvent, +} from './utils/bridge'; import { fetchBridgeTxStatus, getStatusRequestWithSrcTxHash, @@ -109,6 +114,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'resetState', 'submitTx', 'submitIntent', + 'submitBatchSell', 'restartPollingForFailedAttempts', 'getBridgeHistoryItemByTxMetaId', ] as const; @@ -1029,26 +1035,40 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, + maybeQuoteResponses: + | (QuoteResponse & QuoteMetadata) + | (QuoteResponse & QuoteMetadata)[], isStxEnabled: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.MainView, abTests?: Record, activeAbTests?: { key: string; value: string }[], tokenSecurityTypeDestination?: string | null, + batchSellTrades?: BatchSellTradesResponse, ): Promise => { + /** + * If there are multiple quote responses, we assume that they all originate from the same src chain + * and the same account. In this case its safe to use the first quote response's properties for + * metrics and other pre-submission logic + */ + const quoteResponses = Array.isArray(maybeQuoteResponses) + ? maybeQuoteResponses + : [maybeQuoteResponses]; + const quoteResponse = quoteResponses[0]; + const { featureId, quote } = quoteResponse; const startTime = Date.now(); @@ -1104,7 +1124,8 @@ export class BridgeStatusController extends StaticIntervalPollingController = { messenger: this.messenger, - quoteResponse, + quoteResponses, + batchSellTrades, isStxEnabled, isBridgeTx, isDelegatedAccount, @@ -1192,6 +1213,39 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata) | null)[]; + accountAddress: string; + location?: MetaMetricsSwapsEventSource; + abTests?: Record; + activeAbTests?: { key: string; value: string }[]; + isStxEnabled?: boolean; + quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived]; + tokenSecurityTypeDestination?: string | null; + }): Promise => { + /** + * Retrieve the batch sell trades from the BridgeController's state to ensure we submit + * the original response data from the bridge-api + */ + const batchSellTrades = getBatchSellTrades(this.messenger); + return await this.submitTx( + params.accountAddress, + params.quoteResponses.filter( + ( + quoteResponse, + ): quoteResponse is QuoteResponse & QuoteMetadata => + quoteResponse !== null, + ), + params.isStxEnabled ?? false, + params.quotesReceivedContext, + params.location, + params.abTests, + params.activeAbTests, + params.tokenSecurityTypeDestination, + batchSellTrades, + ); + }; + readonly #trackPollingStatusUpdatedEvent = ( historyKey: string, pollingStatus: PollingStatus, diff --git a/packages/bridge-status-controller/src/index.ts b/packages/bridge-status-controller/src/index.ts index c2eb5c432d..dacccea506 100644 --- a/packages/bridge-status-controller/src/index.ts +++ b/packages/bridge-status-controller/src/index.ts @@ -39,3 +39,8 @@ export type { export { BridgeId, BridgeStatusAction } from './types'; export { BridgeStatusController } from './bridge-status-controller'; + +export { + getHistoryItemsForTxHash, + isBatchSellHistoryItem, +} from './utils/history'; diff --git a/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts new file mode 100644 index 0000000000..92a6b2b8dc --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts @@ -0,0 +1,209 @@ +import { BatchSellTradesResponse, TxData } from '@metamask/bridge-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; + +import { + findAllTransactionsInBatch, + getAddTransactionBatchParams, + is7702Tx, + isApprovalTx, + shouldDisable7702, +} from '../utils/transaction'; +import { SubmitStep } from './types'; +import type { SubmitStrategyParams, SubmitStepResult } from './types'; +import { BatchSellTransactionType } from '@metamask/bridge-controller'; +import { QuoteAndTxMetadata } from '../types'; + +/** + * 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* submitBatchSellHandler( + args: SubmitStrategyParams, +): AsyncGenerator { + const { + requireApproval, + quoteResponses, + messenger, + addTransactionBatchFn, + isDelegatedAccount, + batchSellTrades, + } = args; + + const tradeData: QuoteAndTxMetadata[] = []; + + const { + transactions, + fee, + gasIncluded7702, + gasIncluded, + gasSponsored, + // Other properties passed by the backend will be directly passed to TransactionController:addTransactionBatch + ...rest + } = batchSellTrades; + + // Build the trade+quote metadata array for the batch sell transaction + // This ties together the quote, the tx params and the txMeta after submission + for (const transaction of transactions) { + const { type, maxFeePerGas, maxPriorityFeePerGas, ...tx } = transaction; + // Match the trade or approval tx data with the quote response + const matchingQuoteResponse = + quoteResponses.find( + ({ approval, trade }) => + trade?.data.toLowerCase() === tx.data.toLowerCase() || + approval?.data.toLowerCase() === tx.data.toLowerCase(), + ) ?? quoteResponses[0]; + + // Include gasIncluded and gasIncluded7702 from the gasless batch + const normalizedQuote = { + ...matchingQuoteResponse, + quote: { + ...matchingQuoteResponse.quote, + gasIncluded, + gasIncluded7702, + gasSponsored: false, + }, + }; + + const commonTradeData = { + tx, + quoteResponse: normalizedQuote, + txFee: { maxFeePerGas, maxPriorityFeePerGas }, + }; + + if (type === BatchSellTransactionType.TRADE) { + tradeData.push({ + ...commonTradeData, + type: TransactionType.swap, + assetsFiatValues: { + sending: + matchingQuoteResponse.sentAmount?.valueInCurrency?.toString(), + receiving: + matchingQuoteResponse.toTokenAmount?.valueInCurrency?.toString(), + }, + }); + } else { + tradeData.push({ + ...commonTradeData, + type: + type === BatchSellTransactionType.APPROVAL + ? TransactionType.swapApproval + : TransactionType.tokenMethodTransfer, + }); + } + } + + const gasFeeToken = tradeData.find( + ({ type }) => type === TransactionType.tokenMethodTransfer, + )?.tx.to; + + const transactionParams = await getAddTransactionBatchParams({ + messenger, + tradeData, + requireApproval, + isDelegatedAccount, + // Tx success/failure is independent of other txs in the batch + atomic: false, + disable7702: shouldDisable7702( + gasIncluded7702, + gasIncluded, + isDelegatedAccount, + ), + isGasFeeSponsored: gasSponsored, + isGasFeeIncluded: Boolean(gasIncluded7702), + skipInitialGasEstimate: false, + excludeNativeTokenForFee: Boolean(gasFeeToken), + // Properties provided by the obtainGaslessBatch response + ...rest, + }); + + // Submit the batch to the TransactionController + const { batchId } = await addTransactionBatchFn(transactionParams); + + const allTradesWithMetadata = findAllTransactionsInBatch({ + messenger, + batchId, + tradeData, + }); + + // The first tradeMeta (will be either the delegation tx or the first STX swap in the batch) + const firstTradeWithMetadata = allTradesWithMetadata.find( + ({ type, txMeta }) => type === TransactionType.swap && txMeta, + ); + + if (!firstTradeWithMetadata?.txMeta) { + throw new Error( + 'Failed to submit batch sell transaction: tradeMeta not found', + ); + } + + const firstTradeMeta = firstTradeWithMetadata.txMeta; + + if (is7702Tx(firstTradeMeta)) { + const getHistoryKeyForQuote = ({ + quoteResponse: { quoteId, quote }, + }: QuoteAndTxMetadata): string => quoteId ?? quote.requestId; + const quoteIds = allTradesWithMetadata.map(getHistoryKeyForQuote); + + // Create 1 history item for the batch sell tx, keyed by the delegation tx's txMeta.id + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: firstTradeMeta.id, + quoteResponse: firstTradeWithMetadata.quoteResponse, + batchSellData: batchSellTrades, + quoteIds, + bridgeTxMeta: firstTradeMeta, + }, + }; + // Then create a new history item for each trade submitted via 7702, keyed by quoteId + for (const tradeWithMetadata of allTradesWithMetadata) { + const { txMeta, type, quoteResponse } = tradeWithMetadata; + if (isApprovalTx(type) || !txMeta) { + continue; + } + + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: getHistoryKeyForQuote(tradeWithMetadata), + quoteResponse, + batchSellData: batchSellTrades, + }, + }; + } + } else { + // Assume that each trade has its own txMeta if it's not submitted via 7702 + // Create a new history item for each one, keyed by txMeta.id + let approvalTxMeta: TransactionMeta | undefined; + for (const tradeWithMetadata of allTradesWithMetadata) { + const { txMeta, type, quoteResponse } = tradeWithMetadata; + if (isApprovalTx(type) || !txMeta) { + approvalTxMeta = txMeta; + continue; + } + + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: txMeta.id, + quoteResponse, + approvalTxId: approvalTxMeta?.id, + batchSellData: batchSellTrades, + bridgeTxMeta: txMeta, + }, + }; + } + } + + yield { + type: SubmitStep.SetTradeMeta, + payload: { + tradeMeta: firstTradeMeta, + }, + }; +} diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts index fe9685984d..88f7357e69 100644 --- a/packages/bridge-status-controller/src/strategy/batch-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -5,6 +5,7 @@ import { getAddTransactionBatchParams, isApprovalTx, isTradeTx, + shouldDisable7702, toQuoteAndTxMetadata, } from '../utils/transaction'; import { SubmitStep } from './types'; @@ -21,7 +22,7 @@ export async function* submitBatchHandler( ): AsyncGenerator { const { requireApproval, - quoteResponse, + quoteResponses: [quoteResponse], messenger, isBridgeTx, addTransactionBatchFn, @@ -39,14 +40,11 @@ export async function* submitBatchHandler( isDelegatedAccount, messenger, atomic: true, - disable7702: - // Enable 7702 batching when the quote includes gasless 7702 support, - quoteResponse.quote.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. - quoteResponse.quote.gasIncluded, + disable7702: shouldDisable7702( + quoteResponse.quote.gasIncluded7702, + quoteResponse.quote.gasIncluded, + isDelegatedAccount, + ), isGasFeeSponsored: Boolean(quoteResponse.quote.gasSponsored), isGasFeeIncluded: Boolean(quoteResponse.quote.gasIncluded7702), }); diff --git a/packages/bridge-status-controller/src/strategy/evm-strategy.ts b/packages/bridge-status-controller/src/strategy/evm-strategy.ts index 726b07d37c..3fe416e3d5 100644 --- a/packages/bridge-status-controller/src/strategy/evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/evm-strategy.ts @@ -95,7 +95,10 @@ export const handleSingleTx = async ({ * @returns The approvalTxId of the approval transaction */ const approve = async (args: SubmitStrategyParams) => { - const { quoteResponse, isBridgeTx } = args; + const { + quoteResponses: [quoteResponse], + isBridgeTx, + } = args; const { approval, resetApproval } = quoteResponse; if (!approval || !isEvmTxData(approval)) { return undefined; @@ -125,7 +128,7 @@ const approve = async (args: SubmitStrategyParams) => { export const handleEvmApprovals = async (args: SubmitStrategyParams) => await args.traceFn( - getApprovalTraceParams(args.quoteResponse, args.isStxEnabled), + getApprovalTraceParams(args.quoteResponses[0], args.isStxEnabled), async () => await approve(args), ); @@ -138,7 +141,11 @@ export const handleEvmApprovals = async (args: SubmitStrategyParams) => export async function* submitEvmHandler( args: SubmitStrategyParams, ): AsyncGenerator { - const { quoteResponse, requireApproval, isBridgeTx } = args; + const { + quoteResponses: [quoteResponse], + requireApproval, + isBridgeTx, + } = args; // Submit resetApproval and approval transactions if present const approvalTxId = await handleEvmApprovals(args); diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts index 93083e4d8a..cbac55404f 100644 --- a/packages/bridge-status-controller/src/strategy/index.ts +++ b/packages/bridge-status-controller/src/strategy/index.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import { + BatchSellTradesResponse, BitcoinTradeData, ChainId, isBitcoinTrade, @@ -16,19 +17,23 @@ import { submitEvmHandler as defaultSubmitHandler } from './evm-strategy'; import { submitIntentHandler } from './intent-strategy'; import { submitNonEvmHandler } from './non-evm-strategy'; import type { SubmitStrategyParams, SubmitStepResult } from './types'; +import { submitBatchSellHandler } from './batch-sell-strategy'; 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); + const txs = params.quoteResponses + .flatMap((quoteResponse) => [ + quoteResponse.trade, + quoteResponse.approval, + quoteResponse.resetApproval, + ]) + .filter((tx): tx is TxDataType => tx !== undefined); - switch (params.quoteResponse.quote.srcChainId) { + // Assumes all quotes are for the same chain + switch (params.quoteResponses[0].quote.srcChainId) { case ChainId.SOLANA: return txs.every((tx) => typeof tx === 'string'); case ChainId.BTC: @@ -40,6 +45,11 @@ const validateParams = < } }; +const validateBatchSellParams = ( + params: SubmitStrategyParams, +): params is SubmitStrategyParams => + Boolean(params.batchSellTrades) && params.quoteResponses.length > 1; + /** * Selects the appropriate submit strategy based on the quote parameters then executes it * @@ -50,7 +60,11 @@ const validateParams = < const executeSubmitStrategy = ( params: SubmitStrategyParams, ): AsyncGenerator => { - const { quoteResponse, isStxEnabled, isDelegatedAccount } = params; + const { + quoteResponses: [quoteResponse], + isStxEnabled, + isDelegatedAccount, + } = params; // Non-EVM transactions if (isNonEvmChainId(quoteResponse.quote.srcChainId)) { @@ -74,6 +88,11 @@ const executeSubmitStrategy = ( return submitIntentHandler(params); } + // Batch sell transactions + if (validateBatchSellParams(params)) { + return submitBatchSellHandler(params); + } + // Batched transactions const shouldBatchTxs = isStxEnabled || quoteResponse.quote.gasIncluded7702 || isDelegatedAccount; diff --git a/packages/bridge-status-controller/src/strategy/intent-strategy.ts b/packages/bridge-status-controller/src/strategy/intent-strategy.ts index d9c76b2a81..ef35263cfd 100644 --- a/packages/bridge-status-controller/src/strategy/intent-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/intent-strategy.ts @@ -34,7 +34,12 @@ const handleSyntheticTx = async ( orderUid: string, args: SubmitStrategyParams, ) => { - const { quoteResponse, messenger, isBridgeTx, selectedAccount } = args; + const { + quoteResponses: [quoteResponse], + messenger, + isBridgeTx, + selectedAccount, + } = args; const { quote: { srcChainId }, } = quoteResponse; @@ -95,7 +100,7 @@ const handleSyntheticTx = async ( */ const handleSubmitIntent = async (args: SubmitStrategyParams) => { const { - quoteResponse, + quoteResponses: [quoteResponse], messenger, selectedAccount, clientId, @@ -188,7 +193,7 @@ export async function* submitIntentHandler( approvalTxId, // Keep original txId for TransactionController updates originalTransactionId: syntheticTxMeta?.id, - quoteResponse: args.quoteResponse, + quoteResponse: args.quoteResponses[0], }, }; 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 b6e430d7fc..1c57c21bf6 100644 --- a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts @@ -23,7 +23,10 @@ const handleTronApproval = async ( TronTradeData | BitcoinTradeData | string | TxData >, ) => { - const { quoteResponse, traceFn } = args; + const { + quoteResponses: [quoteResponse], + traceFn, + } = args; const approvalTxId = await traceFn( getApprovalTraceParams(quoteResponse, false), @@ -65,7 +68,10 @@ export async function* submitNonEvmHandler( BitcoinTradeData | TronTradeData | string | TxData >, ): AsyncGenerator { - const { quoteResponse, isBridgeTx } = args; + const { + quoteResponses: [quoteResponse], + isBridgeTx, + } = args; 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 dcb54950e9..0659f7b14d 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -1,5 +1,6 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { + BatchSellTradesResponse, BridgeClientId, QuoteMetadata, QuoteResponse, @@ -40,6 +41,8 @@ export type SubmitStepResult = > & { historyKey: string; quoteResponse: QuoteResponse & QuoteMetadata; + batchSellData?: BatchSellTradesResponse; + quoteIds?: string[]; }; } | { @@ -84,13 +87,19 @@ export type SubmitStepResult = /** * The parameters for the submission flow */ -export type SubmitStrategyParams = { +export type SubmitStrategyParams< + TradeType extends Trade = TxData, + BatchSellTradesResponseType extends BatchSellTradesResponse | undefined = + | BatchSellTradesResponse + | undefined, +> = { + batchSellTrades: BatchSellTradesResponseType; addTransactionBatchFn: TransactionController['addTransactionBatch']; isBridgeTx: boolean; isDelegatedAccount: boolean; isStxEnabled: boolean; messenger: BridgeStatusControllerMessenger; - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponses: (QuoteResponse & QuoteMetadata)[]; requireApproval: boolean; selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string]; traceFn: TraceCallback; diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index e0ba2cd75a..a593cf1587 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -13,8 +13,10 @@ import type { SimulatedGasFeeLimits, TxData, TxFeeGasLimits, - BridgeControllerTrackUnifiedSwapBridgeEventAction, BridgeControllerStopPollingForQuotesAction, + BridgeControllerTrackUnifiedSwapBridgeEventAction, + BatchSellTradesResponse, + BridgeControllerGetStateAction, } from '@metamask/bridge-controller'; import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; @@ -139,6 +141,11 @@ export type BridgeHistoryItem = { */ originalTransactionId?: string; // Keep original transaction ID for intent transactions batchId?: string; + /** + * This is defined when the history item is for a batch sell transaction + */ + batchSellData?: BatchSellTradesResponse; + quoteIds?: string[]; quote: Quote; status: StatusResponse; startTime: number; // timestamp in ms @@ -253,6 +260,8 @@ export type QuoteMetadataSerialized = { export type StartPollingForBridgeTxStatusArgs = { bridgeTxMeta?: Pick; actionId?: string; + batchSellData?: BridgeHistoryItem['batchSellData']; + quoteIds?: BridgeHistoryItem['quoteIds']; /** * @deprecated the txMeta or orderUid should be used instead */ @@ -337,6 +346,7 @@ type AllowedActions = | TransactionControllerIsAtomicBatchSupportedAction | BridgeControllerTrackUnifiedSwapBridgeEventAction | BridgeControllerStopPollingForQuotesAction + | BridgeControllerGetStateAction | AccountsControllerGetAccountByAddressAction | AuthenticationControllerGetBearerTokenAction | KeyringControllerSignTypedMessageAction; diff --git a/packages/bridge-status-controller/src/utils/bridge.ts b/packages/bridge-status-controller/src/utils/bridge.ts index b5a94ff3a7..ba4f826966 100644 --- a/packages/bridge-status-controller/src/utils/bridge.ts +++ b/packages/bridge-status-controller/src/utils/bridge.ts @@ -2,8 +2,9 @@ import { AbortReason, FeatureId, UnifiedSwapBridgeEventName, + BatchSellTradesResponse, + RequiredEventContextFromClient, } from '@metamask/bridge-controller'; -import type { RequiredEventContextFromClient } from '@metamask/bridge-controller'; import { BridgeStatusControllerMessenger } from '../types'; @@ -21,6 +22,14 @@ export const stopPollingForQuotes = ( ); }; +export const getBatchSellTrades = ( + messenger: BridgeStatusControllerMessenger, +): BatchSellTradesResponse | undefined => { + return ( + messenger.call('BridgeController:getState').batchSellTrades ?? undefined + ); +}; + export const trackMetricsEvent = ({ messenger, eventName, diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index af227514fa..73db435204 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -51,6 +51,10 @@ export const rekeyHistoryItemInState = ( return true; }; +export const isBatchSellHistoryItem = ( + historyItem: BridgeHistoryItem, +): boolean => Boolean(historyItem?.batchSellData); + /** * Returns the history entry that matches the txMeta by id, actionId, batchId, or txHash * @@ -78,7 +82,12 @@ export const getMatchingHistoryEntryForTxMeta = ( key === txMeta.actionId || txMetaId === txMeta.id || (actionId ? actionId === txMeta.actionId : false) || - (batchId ? batchId === txMeta.batchId : false) || + // When the batch is not atomic (BatchSell), ignore batchId matching to prevent txs + // in the batch from getting marked complete/failed too early if one fails + // Multiple BatchSell STX trades may have the same batchId + (Boolean(batchId) && + !isBatchSellHistoryItem(value) && + batchId === txMeta.batchId) || (txHash ? txHash.toLowerCase() === txMeta.hash?.toLowerCase() : false) ); }); @@ -102,6 +111,37 @@ export const getMatchingHistoryEntryForApprovalTxMeta = ( ); }; +/** + * Returns the BatchSell history items for a given tx hash or batchId. + * + * @param txHashOrBatchId - The tx hash of the STX transaction or the delegation tx for a 7702 batch, or the batchId + * @param txHistory - The bridge status controller's history to search for matching history items + * @returns The matching history items for the tx hash and a boolean indicating if it's a 7702 batch + */ +export const getHistoryItemsForTxHash = ( + txHashOrBatchId: string, + txHistory: BridgeStatusControllerState['txHistory'], +): { historyItems: BridgeHistoryItem[]; is7702Batch: boolean } => { + const historyItems = Object.values(txHistory); + + /** + * Either a delegation tx or a list of STX BatchSell trades + */ + const matchingHistoryItems = historyItems.filter( + ({ status }) => + status.srcChain.txHash?.toLowerCase() === txHashOrBatchId.toLowerCase(), + ); + const [historyItem] = matchingHistoryItems; + + return { + historyItems: (historyItem?.quoteIds?.length + ? historyItem.quoteIds.map((quoteId) => txHistory[quoteId]) + : matchingHistoryItems + )?.filter((item) => item !== undefined), + is7702Batch: Boolean(historyItem?.quoteIds), + }; +}; + /** * Determines the key to use for storing a bridge history item. * Uses actionId for pre-submission tracking, or bridgeTxMetaId for post-submission. @@ -146,11 +186,13 @@ export const getInitialHistoryItem = ( originalTransactionId, actionId, tokenSecurityTypeDestination, + batchSellData, + quoteIds, } = args; // 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 - const txHistoryItem = { + const txHistoryItem: BridgeHistoryItem = { txMetaId: bridgeTxMeta?.id, actionId, originalTransactionId: originalTransactionId ?? bridgeTxMeta?.id, // Keep original for intent transactions @@ -196,6 +238,13 @@ export const getInitialHistoryItem = ( }), }; + if (batchSellData) { + txHistoryItem.batchSellData = batchSellData; + } + if (quoteIds) { + txHistoryItem.quoteIds = quoteIds; + } + return txHistoryItem; }; diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index d69790b9ed..3598344746 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -51,7 +51,7 @@ export const isCrossChainTx = (type: TransactionType) => * @param tx - The transaction meta * @returns Whether the transaction is a 7702 transaction */ -const is7702Tx = (tx: TransactionMeta) => { +export const is7702Tx = (tx: TransactionMeta) => { return ( (Array.isArray(tx.txParams.authorizationList) && tx.txParams.authorizationList.length > 0) || @@ -59,6 +59,20 @@ const is7702Tx = (tx: TransactionMeta) => { ); }; +export const shouldDisable7702 = ( + gasIncluded7702?: boolean, + gasIncluded?: boolean, + isDelegatedAccount?: boolean, +) => { + // Enable 7702 batching when the quote includes gasless 7702 support + return 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; +}; + export const getGasFeeEstimates = async ( messenger: BridgeStatusControllerMessenger, args: Parameters[0],