From 5359aa291ba76f164a5c48cbffe0934188b7fc9b Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 8 May 2026 14:49:53 -0700 Subject: [PATCH 1/9] feat: handle multiple quoteResponses in submit params --- .../src/bridge-status-controller.ts | 100 ++++++++++-------- .../src/strategy/batch-strategy.ts | 8 +- .../src/strategy/evm-strategy.ts | 17 +-- .../src/strategy/index.ts | 21 ++-- .../src/strategy/intent-strategy.ts | 9 +- .../src/strategy/non-evm-strategy.ts | 10 +- .../src/strategy/types.ts | 4 +- .../bridge-status-controller/src/types.ts | 86 +++++++++++++++ 8 files changed, 190 insertions(+), 65 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index d8ac782b40..ac7eaf7185 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -44,6 +44,9 @@ import type { StartPollingForBridgeTxStatusArgsSerialized, FetchFunction, BridgeHistoryItem, + SubmitTxParams, + SubmitTxLegacyParams, + QuoteResponseParam, } from './types'; import type { BridgeStatusControllerMessenger } from './types'; import { BridgeClientId } from './types'; @@ -963,8 +966,8 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - let tradeTxMeta!: TransactionMeta; + ): Promise => { + const tradeTxMetas: TransactionMeta[] = []; const steps = executeSubmitStrategy(params); @@ -981,14 +984,15 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, - isStxEnabled: boolean, - quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], - location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.MainView, - abTests?: Record, - activeAbTests?: { key: string; value: string }[], - tokenSecurityTypeDestination?: string | null, - ): Promise => { + ...params: SubmitTxLegacyParams | [SubmitTxParams] + ): Promise => { + // Both legacy and new parameter formats are supported so transform legacy parameters into new parameters if needed + const { + accountAddress, + quoteResponses, + isStxEnabled = false, + quotesReceivedContext, + location = MetaMetricsSwapsEventSource.MainView, + abTests, + activeAbTests, + tokenSecurityTypeDestination, + } = typeof params[0] === 'object' + ? params[0] + : ({ + accountAddress: params[0], + quoteResponse: params[1], + // Transform quoteResponse parameter into quoteResponses parameter + quoteResponses: Array.isArray(params[1]) ? params[1] : [params[1]], + isStxEnabled: params[2], + quotesReceivedContext: params[3], + location: params[4], + abTests: params[5], + activeAbTests: params[6], + tokenSecurityTypeDestination: params[7], + } as SubmitTxParams); + + /** + * 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 quoteResponse = quoteResponses[0]; + const { featureId, quote } = quoteResponse; const startTime = Date.now(); @@ -1101,7 +1123,7 @@ export class BridgeStatusController extends StaticIntervalPollingController = { messenger: this.messenger, - quoteResponse, + quoteResponses, isStxEnabled, isBridgeTx, isDelegatedAccount, @@ -1114,7 +1136,7 @@ export class BridgeStatusController extends StaticIntervalPollingController await this.#executeSubmitStrategy(strategyParams, { @@ -1125,6 +1147,10 @@ export class BridgeStatusController extends StaticIntervalPollingController 1 ? tradeTxMetas : tradeTxMetas[0]; } catch (error) { this.#trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.Failed, @@ -1165,28 +1191,12 @@ export class BridgeStatusController extends StaticIntervalPollingController> => { - const { - quoteResponse, - accountAddress, - location, - abTests, - activeAbTests, - isStxEnabled = false, - quotesReceivedContext, - tokenSecurityTypeDestination, - } = params; - // TODO add metrics context - return await this.submitTx( - accountAddress, - quoteResponse, - isStxEnabled, - quotesReceivedContext, - location, - abTests, - activeAbTests, - tokenSecurityTypeDestination, - ); + const txMetas = await this.submitTx({ + ...params, + quoteResponses: [params.quoteResponse], + }); + return Array.isArray(txMetas) ? txMetas[0] : txMetas; }; readonly #trackPollingStatusUpdatedEvent = ( diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts index 321a24a9f3..bdc4ce7f09 100644 --- a/packages/bridge-status-controller/src/strategy/batch-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -19,13 +19,16 @@ export async function* submitBatchHandler( ): AsyncGenerator { const { requireApproval, - quoteResponse, + quoteResponses, messenger, isBridgeTx, addTransactionBatchFn, isDelegatedAccount, } = args; + const quoteRequestIndex = 0; + const quoteResponse = quoteResponses[quoteRequestIndex]; + const tradeData: Parameters< typeof getAddTransactionBatchParams >[0]['tradeData'] = []; @@ -73,7 +76,7 @@ export async function* submitBatchHandler( yield { type: SubmitStep.SetTradeMeta, - payload: { tradeMeta }, + payload: { tradeMeta, quoteRequestIndex }, }; yield { @@ -86,6 +89,7 @@ export async function* submitBatchHandler( hash: tradeMeta.hash, batchId: tradeMeta.batchId, }, + quoteRequestIndex, }, }; } diff --git a/packages/bridge-status-controller/src/strategy/evm-strategy.ts b/packages/bridge-status-controller/src/strategy/evm-strategy.ts index 27313109fb..dcda9d0c80 100644 --- a/packages/bridge-status-controller/src/strategy/evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/evm-strategy.ts @@ -50,7 +50,10 @@ 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; @@ -76,7 +79,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), ); @@ -89,7 +92,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); @@ -149,8 +156,6 @@ export async function* submitEvmHandler( yield { type: SubmitStep.SetTradeMeta, - payload: { - tradeMeta, - }, + payload: { tradeMeta }, }; } diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts index 93083e4d8a..1300ab6ce4 100644 --- a/packages/bridge-status-controller/src/strategy/index.ts +++ b/packages/bridge-status-controller/src/strategy/index.ts @@ -22,13 +22,16 @@ const validateParams = < >( 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: @@ -50,7 +53,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)) { diff --git a/packages/bridge-status-controller/src/strategy/intent-strategy.ts b/packages/bridge-status-controller/src/strategy/intent-strategy.ts index b11078ba3c..4dc1e4ad7b 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, 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 44accdd7e2..3b5dfe7238 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 f895b7c7de..d968dc339b 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -37,6 +37,7 @@ export type SubmitStepResult = 'approvalTxId' | 'bridgeTxMeta' | 'originalTransactionId' | 'actionId' > & { historyKey: string; + quoteRequestIndex?: number; }; } | { @@ -69,6 +70,7 @@ export type SubmitStepResult = /** The {@link TransactionMeta} for the transaction that has been submitted successfully */ payload: { tradeMeta: TransactionMeta; + quoteRequestIndex?: number; }; }; @@ -81,7 +83,7 @@ export type SubmitStrategyParams = { 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 6b01432a4b..ec0c22da01 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -12,6 +12,10 @@ import type { QuoteMetadata, QuoteResponse, MetaMetricsSwapsEventSource, + Trade, + RequiredEventContextFromClient, + UnifiedSwapBridgeEventName, + ObtainGaslessBatchResponse, } from '@metamask/bridge-controller'; import type { GetGasFeeState } from '@metamask/gas-fee-controller'; import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; @@ -53,6 +57,88 @@ export type FetchFunction = ( init?: RequestInit, ) => Promise; +type LegacyQuoteResponseParam = { + /** + * A quote response + * + * @deprecated use quoteResponses instead + */ + quoteResponse: QuoteResponse & QuoteMetadata; +}; + +export type QuoteResponseParam< + QuoteResponseType = QuoteResponse & QuoteMetadata, +> = { + /** + * An array of quote responses + */ + quoteResponses: [QuoteResponseType, ...QuoteResponseType[]]; +}; + +export type SubmitTxParams< + QRPType extends + | LegacyQuoteResponseParam + | QuoteResponseParam = QuoteResponseParam, +> = { + /** + * The address of the account to submit the transaction for + */ + accountAddress: string; + /** + * Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension + */ + isStxEnabled?: boolean; + /** + * The context for the QuotesReceived event + */ + quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived]; + /** + * The location/entry point from which the user initiated the swap or bridge. + * Used to attribute swaps to specific flows (e.g. Trending Explore). + */ + location?: MetaMetricsSwapsEventSource; + /** + * Legacy A/B test metrics context (`ab_tests`) kept for backward compatibility. + * Keys are test names, values are variant names (e.g. { token_details_layout: 'treatment' }). + */ + abTests?: Record; + /** + * New A/B test metrics context (`active_ab_tests`) that replaces `ab_tests`. + * Kept separate so migration can run both payloads in parallel. + * This field is an array of test objects. + */ + activeAbTests?: { key: string; value: string }[]; + /** + * The security classification of the destination token, supplied by the client + * (e.g. from token security/scanning data). Pass `null` when no security data is available. + */ + tokenSecurityTypeDestination?: string | null; + /** + * Contains transaction data for the quotes, + * provided by the obtainGaslessBatch API + */ + gaslessBatchSellResponse?: ObtainGaslessBatchResponse; +} & QRPType; + +/** + * The legacy parameters for the transaction submission + * + * @deprecated Use {@link SubmitTxParams} instead + */ +export type SubmitTxLegacyParams = Parameters< + ( + accountAddress: SubmitTxParams['accountAddress'], + quoteResponse: QuoteResponse & QuoteMetadata, + isStxEnabled: SubmitTxParams['isStxEnabled'], + quotesReceivedContext?: SubmitTxParams['quotesReceivedContext'], + location?: SubmitTxParams['location'], + abTests?: SubmitTxParams['abTests'], + activeAbTests?: SubmitTxParams['activeAbTests'], + tokenSecurityTypeDestination?: SubmitTxParams['tokenSecurityTypeDestination'], + gaslessBatchSellResponse?: SubmitTxParams['gaslessBatchSellResponse'], + ) => void +>; + /** * These fields are specific to Solana transactions and can likely be infered from TransactionMeta * From 191fb1f2b0551388a832a1aeb4b26875c60ba25e Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 8 May 2026 14:52:07 -0700 Subject: [PATCH 2/9] chore: BatchSimulationResponse --- packages/bridge-controller/src/index.ts | 1 + packages/bridge-controller/src/types.ts | 8 ++++++++ packages/bridge-controller/src/utils/validators.ts | 8 ++++++++ 3 files changed, 17 insertions(+) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index eb42636685..bfa5bdf56a 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -40,6 +40,7 @@ export type { BridgeAsset, GenericQuoteRequest, Protocol, + BatchSimulationResponse, TokenAmountValues, Step, RefuelData, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 5581bd6560..03deaabf1c 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -47,6 +47,7 @@ import type { QuoteStreamCompleteSchema, TronTradeDataSchema, TxDataSchema, + BatchSimulationResponseSchema, } from './utils/validators'; export type FetchFunction = ( @@ -305,6 +306,13 @@ export type QuoteResponse< resetApproval?: TxData; }; +/** + * This is the bridge-api response for the obtainGaslessBatch method + */ +export type BatchSimulationResponse = Infer< + typeof BatchSimulationResponseSchema +>; + export enum ChainId { ETH = 1, OPTIMISM = 10, diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 12cd1780b3..e7e35f024e 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -526,3 +526,11 @@ export const validateQuoteStreamComplete = ( assert(data, QuoteStreamCompleteSchema); return true; }; + +export const BatchSimulationResponseSchema = type({ + transactions: array(TxDataSchema), + fee: type({ + destAsset: BridgeAssetSchema, + amount: string(), + }), +}); From cf59c1c2f5268120d9ec95f9c0c45ea36463b93b Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 11 May 2026 17:07:02 -0700 Subject: [PATCH 3/9] refactor: transaction params, gas calculation --- packages/bridge-controller/src/index.ts | 2 + packages/bridge-controller/src/types.ts | 5 + .../bridge-controller/src/utils/validators.ts | 121 ++++--- .../bridge-status-controller.test.ts.snap | 228 ++---------- .../src/bridge-status-controller.test.ts | 53 +-- .../src/bridge-status-controller.ts | 3 + .../src/strategy/batch-strategy.ts | 67 +++- .../src/strategy/types.ts | 2 + .../bridge-status-controller/src/types.ts | 6 +- .../src/utils/gas.test.ts | 10 +- .../src/utils/transaction.test.ts | 83 +++-- .../src/utils/transaction.ts | 336 ++++++------------ 12 files changed, 336 insertions(+), 580 deletions(-) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index bfa5bdf56a..58038e42d9 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -41,6 +41,8 @@ export type { GenericQuoteRequest, Protocol, BatchSimulationResponse, + GaslessProperties, + SimulatedGasFeeLimits, TokenAmountValues, Step, RefuelData, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 03deaabf1c..22496dc3b4 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -48,6 +48,8 @@ import type { TronTradeDataSchema, TxDataSchema, BatchSimulationResponseSchema, + GaslessPropertiesSchema, + SimulatedGasFeeLimitsSchema, } from './utils/validators'; export type FetchFunction = ( @@ -313,6 +315,9 @@ export type BatchSimulationResponse = Infer< typeof BatchSimulationResponseSchema >; +export type SimulatedGasFeeLimits = Infer; +export type GaslessProperties = Infer; + export enum ChainId { ETH = 1, OPTIMISM = 10, diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index e7e35f024e..aafb167133 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { isValidHexAddress } from '@metamask/controller-utils'; +import { isValidHexAddress, toHex } from '@metamask/controller-utils'; import type { Infer } from '@metamask/superstruct'; import { any, @@ -18,6 +18,8 @@ import { assert, pattern, intersection, + pick, + coerce, } from '@metamask/superstruct'; import { CaipAssetTypeStruct, @@ -369,65 +371,70 @@ export const IntentSchema = type({ }), }); -export const QuoteSchema = type({ - requestId: string(), - srcChainId: ChainIdSchema, - srcAsset: BridgeAssetSchema, - /** - * The amount sent, in atomic amount: amount sent - fees - * Some tokens have a fee of 0, so sometimes it's equal to amount sent - */ - srcTokenAmount: string(), - destChainId: ChainIdSchema, - destAsset: BridgeAssetSchema, - /** - * The amount received, in atomic amount - */ - destTokenAmount: string(), - /** - * The minimum amount that will be received, in atomic amount - */ - minDestTokenAmount: string(), - feeData: type({ - [FeeType.METABRIDGE]: FeeDataSchema, - /** - * This is the fee for the swap transaction taken from either the - * src or dest token if the quote has gas fees included or "gasless" - */ - [FeeType.TX_FEE]: optional( - intersection([ - FeeDataSchema, - type({ - maxFeePerGas: string(), - maxPriorityFeePerGas: string(), - }), - ]), - ), - }), +export const SimulatedGasFeeLimitsSchema = type({ + maxFeePerGas: string(), + maxPriorityFeePerGas: string(), +}); + +export const GaslessPropertiesSchema = type({ gasIncluded: optional(boolean()), /** * Whether the quote can use EIP-7702 delegated gasless execution */ gasIncluded7702: optional(boolean()), - bridgeId: string(), - bridges: array(string()), - steps: array(StepSchema), - refuel: optional(RefuelDataSchema), - priceData: optional( - type({ - totalFromAmountUsd: optional(string()), - totalToAmountUsd: optional(string()), - priceImpact: optional(string()), - totalFeeAmountUsd: optional(string()), - }), - ), - intent: optional(IntentSchema), /** * A third party sponsors the gas. If true, then gasIncluded7702 is also true. */ gasSponsored: optional(boolean()), }); +export const QuoteSchema = intersection([ + GaslessPropertiesSchema, + type({ + requestId: string(), + srcChainId: ChainIdSchema, + srcAsset: BridgeAssetSchema, + /** + * The amount sent, in atomic amount: amount sent - fees + * Some tokens have a fee of 0, so sometimes it's equal to amount sent + */ + srcTokenAmount: string(), + destChainId: ChainIdSchema, + destAsset: BridgeAssetSchema, + /** + * The amount received, in atomic amount + */ + destTokenAmount: string(), + /** + * The minimum amount that will be received, in atomic amount + */ + minDestTokenAmount: string(), + feeData: type({ + [FeeType.METABRIDGE]: FeeDataSchema, + /** + * This is the fee for the swap transaction taken from either the + * src or dest token if the quote has gas fees included or "gasless" + */ + [FeeType.TX_FEE]: optional( + intersection([FeeDataSchema, SimulatedGasFeeLimitsSchema]), + ), + }), + bridgeId: string(), + bridges: array(string()), + steps: array(StepSchema), + refuel: optional(RefuelDataSchema), + priceData: optional( + type({ + totalFromAmountUsd: optional(string()), + totalToAmountUsd: optional(string()), + priceImpact: optional(string()), + totalFeeAmountUsd: optional(string()), + }), + ), + intent: optional(IntentSchema), + }), +]); + export const TxDataSchema = type({ chainId: number(), to: HexAddressSchema, @@ -527,10 +534,22 @@ export const validateQuoteStreamComplete = ( return true; }; +// TODO should this be here or in the bridge-status-controller? export const BatchSimulationResponseSchema = type({ - transactions: array(TxDataSchema), + transactions: array( + intersection([ + TxDataSchema, + SimulatedGasFeeLimitsSchema, + /** + * The assetId of the srcToken, used to match the simulation response with the quote response + */ + pick(BridgeAssetSchema, ['assetId']), + // updateTransactions won't work for these because we won't know the tx type + type({ type: enums(['swap', 'approval', 'bridge']) }), + ]), + ), fee: type({ - destAsset: BridgeAssetSchema, + asset: BridgeAssetSchema, amount: string(), }), }); 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 0b062848fb..a7b0771738 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 @@ -652,20 +652,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -674,11 +669,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -703,18 +696,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", + "gas": undefined, "to": "0xbridgeContract", "value": "0x0", }, @@ -723,7 +713,6 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": undefined, @@ -975,20 +964,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -997,11 +981,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -1026,18 +1008,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", + "gas": undefined, "to": "0xbridgeContract", "value": "0x0", }, @@ -1046,7 +1025,6 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": undefined, @@ -1264,8 +1242,8 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "maxFeePerGas": "0x0", - "maxPriorityFeePerGas": "0x0", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", "value": "0x0", }, @@ -1347,9 +1325,6 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { @@ -1358,7 +1333,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac "transactionParams": { "data": "0xdata", "from": "0xaccount1", - "gas": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -1599,20 +1574,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -1621,11 +1591,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -1650,20 +1618,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -1672,11 +1635,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", @@ -1925,20 +1886,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -1947,11 +1903,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -1976,20 +1930,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -1998,11 +1947,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", @@ -2251,20 +2198,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance "NetworkController:findNetworkClientIdByChainId", "0x1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0x1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0x1", "data": "0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -2273,11 +2215,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance [ "TransactionController:addTransaction", { - "chainId": "0x1", "data": "0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -2302,20 +2242,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -2324,11 +2259,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -2353,20 +2286,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -2375,11 +2303,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", @@ -2603,20 +2529,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -2625,11 +2546,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -2654,20 +2573,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -2676,11 +2590,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", @@ -2904,20 +2816,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -2926,11 +2833,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", @@ -3006,20 +2911,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -3028,11 +2928,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -3134,20 +3032,15 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -3156,11 +3049,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -3413,20 +3304,15 @@ exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confi "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -3435,11 +3321,9 @@ exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confi [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -3464,20 +3348,15 @@ exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confi "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -3486,11 +3365,9 @@ exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confi [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", @@ -3610,20 +3487,15 @@ exports[`BridgeStatusController submitTx: EVM swap should gracefully handle isAt "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -3632,11 +3504,9 @@ exports[`BridgeStatusController submitTx: EVM swap should gracefully handle isAt [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -3661,20 +3531,15 @@ exports[`BridgeStatusController submitTx: EVM swap should gracefully handle isAt "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -3683,11 +3548,9 @@ exports[`BridgeStatusController submitTx: EVM swap should gracefully handle isAt [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", @@ -3985,8 +3848,8 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "maxFeePerGas": "0x0", - "maxPriorityFeePerGas": "0x0", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", "value": "0x0", }, @@ -4001,8 +3864,8 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "maxFeePerGas": "0x0", - "maxPriorityFeePerGas": "0x0", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", "value": "0x0", }, @@ -4070,9 +3933,6 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { @@ -4081,15 +3941,12 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti "transactionParams": { "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, }, ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { @@ -4098,7 +3955,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti "transactionParams": { "data": "0xdata", "from": "0xaccount1", - "gas": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -4197,20 +4054,15 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", "transactionParams": { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xtokenContract", "value": "0x0", }, @@ -4219,11 +4071,9 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xtokenContract", @@ -4248,20 +4098,15 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -4270,11 +4115,9 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", @@ -4494,20 +4337,15 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { "chainId": "0xa4b1", "networkClientId": "arbitrum", "transactionParams": { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", - "gas": "21000", - "gasLimit": "21000", + "gas": "0x5208", "to": "0xbridgeContract", "value": "0x0", }, @@ -4516,11 +4354,9 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an [ "TransactionController:addTransaction", { - "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", - "gasLimit": "21000", "maxFeePerGas": undefined, "maxPriorityFeePerGas": undefined, "to": "0xbridgeContract", 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..776e013f5c 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -2866,9 +2866,6 @@ describe('BridgeStatusController', () => { const setupApprovalMocks = (mockCall: jest.Mock) => { mockCall.mockReturnValueOnce(mockSelectedAccount); mockCall.mockReturnValueOnce('arbitrum-client-id'); - mockCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); mockMessengerCall.mockResolvedValueOnce({ transactionMeta: mockApprovalTxMeta, @@ -2882,9 +2879,6 @@ describe('BridgeStatusController', () => { const setupBridgeMocks = (mockCall: jest.Mock) => { mockCall.mockReturnValueOnce(mockSelectedAccount); mockCall.mockReturnValueOnce('arbitrum'); - mockCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockCall.mockResolvedValueOnce(mockEstimateGasFeeResult); mockCall.mockResolvedValueOnce({ transactionMeta: mockEvmTxMeta, @@ -2904,9 +2898,6 @@ describe('BridgeStatusController', () => { const setupBridgeStxMocks = (mockCall: jest.Mock) => { mockCall.mockReturnValueOnce(mockSelectedAccount); mockCall.mockReturnValueOnce('arbitrum'); - mockCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockCall.mockResolvedValueOnce(mockEstimateGasFeeResult); addTransactionBatchFn.mockResolvedValueOnce({ batchId: 'batchId1', @@ -3191,17 +3182,8 @@ describe('BridgeStatusController', () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); mockMessengerCall.mockReturnValueOnce('arbitrum'); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); addTransactionBatchFn.mockResolvedValueOnce({ batchId: 'batchId1', @@ -3260,7 +3242,7 @@ describe('BridgeStatusController', () => { action === 'TransactionController:updateTransaction', ), ).toHaveLength(1); - expect(mockMessengerCall).toHaveBeenCalledTimes(14); + expect(mockMessengerCall).toHaveBeenCalledTimes(11); }, ); }); @@ -3269,9 +3251,6 @@ describe('BridgeStatusController', () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); mockMessengerCall.mockReturnValueOnce('arbitrum-client-id'); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); mockMessengerCall.mockRejectedValueOnce(new Error('Approval tx failed')); @@ -3297,9 +3276,6 @@ describe('BridgeStatusController', () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); mockMessengerCall.mockReturnValueOnce('arbitrum-client-id'); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); mockMessengerCall.mockResolvedValueOnce({ transactionMeta: undefined, @@ -3659,9 +3635,6 @@ describe('BridgeStatusController', () => { // Setup for trade tx (no approval) mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); mockMessengerCall.mockReturnValueOnce('arbitrum-client-id'); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce({ estimates: { high: { @@ -3862,9 +3835,6 @@ describe('BridgeStatusController', () => { const setupApprovalMocks = () => { mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); mockMessengerCall.mockReturnValueOnce('arbitrum-client-id'); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); mockMessengerCall.mockResolvedValueOnce({ transactionMeta: mockApprovalTxMeta, @@ -3878,9 +3848,6 @@ describe('BridgeStatusController', () => { const setupBridgeMocks = () => { mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); mockMessengerCall.mockReturnValueOnce('arbitrum'); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); mockMessengerCall.mockResolvedValueOnce({ transactionMeta: mockEvmTxMeta, @@ -3922,7 +3889,7 @@ describe('BridgeStatusController', () => { ([action]) => action === 'TransactionController:addTransaction', ), ).toHaveLength(2); - expect(mockMessengerCall).toHaveBeenCalledTimes(16); + expect(mockMessengerCall).toHaveBeenCalledTimes(14); }, ); }); @@ -4382,13 +4349,7 @@ describe('BridgeStatusController', () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); mockMessengerCall.mockReturnValueOnce('arbitrum'); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); addTransactionBatchFn.mockResolvedValueOnce({ batchId: 'batchId1', @@ -4508,13 +4469,7 @@ describe('BridgeStatusController', () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); mockMessengerCall.mockReturnValueOnce('arbitrum'); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); - mockMessengerCall.mockReturnValueOnce({ - gasFeeEstimates: { estimatedBaseFee: '0x1234' }, - }); mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); addTransactionBatchFn.mockResolvedValueOnce({ batchId: 'batchId1', @@ -4555,7 +4510,7 @@ describe('BridgeStatusController', () => { ), ).toHaveLength(0); expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); - expect(mockMessengerCall).toHaveBeenCalledTimes(12); + expect(mockMessengerCall).toHaveBeenCalledTimes(10); expect( mockCalls.find( ([action, eventName]) => @@ -4631,7 +4586,7 @@ describe('BridgeStatusController', () => { ([action]) => action === 'TransactionController:addTransaction', ), ).toHaveLength(2); - expect(mockMessengerCall).toHaveBeenCalledTimes(16); + expect(mockMessengerCall).toHaveBeenCalledTimes(14); expect(addTransactionBatchFn).not.toHaveBeenCalled(); expect(mockCalls).toMatchSnapshot(); expect(result).toMatchInlineSnapshot(` diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index ac7eaf7185..764eab41e0 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1046,6 +1046,7 @@ export class BridgeStatusController extends StaticIntervalPollingController); /** @@ -1124,6 +1126,7 @@ export class BridgeStatusController extends StaticIntervalPollingController = { messenger: this.messenger, quoteResponses, + batchSimulationResponse, isStxEnabled, isBridgeTx, isDelegatedAccount, diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts index bdc4ce7f09..f988a68b81 100644 --- a/packages/bridge-status-controller/src/strategy/batch-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -1,9 +1,10 @@ -import { TxData } from '@metamask/bridge-controller'; +import { FeeType, TxData } from '@metamask/bridge-controller'; import { TransactionType } from '@metamask/transaction-controller'; import { addTransactionBatch, getAddTransactionBatchParams, + TradeWithMetadata, } from '../utils/transaction'; import { SubmitStep } from './types'; import type { SubmitStrategyParams, SubmitStepResult } from './types'; @@ -24,48 +25,80 @@ export async function* submitBatchHandler( isBridgeTx, addTransactionBatchFn, isDelegatedAccount, + // batchSimulationResponse, } = args; const quoteRequestIndex = 0; - const quoteResponse = quoteResponses[quoteRequestIndex]; - - const tradeData: Parameters< - typeof getAddTransactionBatchParams - >[0]['tradeData'] = []; const approvalTxType = isBridgeTx ? TransactionType.bridgeApproval : TransactionType.swapApproval; + const tradeData: TradeWithMetadata[] = []; + + // if (batchSimulationResponse) { + // const { transactions } = batchSimulationResponse; + + // batchSimulationResponse.transactions.forEach( + // ({ assetId, type, maxFeePerGas, maxPriorityFeePerGas, ...tx }) => { + // const quoteResponse = quoteResponses.find( + // ({ quote }) => quote.srcAsset.assetId === transactions[0].assetId, + // ); + // tradeData.push({ + // tx, + // type: + // type === 'swap' + // ? TransactionType.swap + // : TransactionType.swapApproval, + // // If there is no matching quote response, these will be undefined + // assetsFiatValues: { + // sending: quoteResponse?.sentAmount?.valueInCurrency?.toString(), + // receiving: + // quoteResponse?.toTokenAmount?.valueInCurrency?.toString(), + // }, + // txFee: { maxFeePerGas, maxPriorityFeePerGas }, + // }); + // }, + // ); + // } else { + const quoteResponse = quoteResponses[quoteRequestIndex]; if (quoteResponse.resetApproval) { tradeData.push({ tx: quoteResponse.resetApproval, type: approvalTxType, + // TODO for regular 7702, shoudl txFee be appended to both approval and trade? + // I think it covers both + txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], }); } if (quoteResponse.approval) { tradeData.push({ tx: quoteResponse.approval, type: approvalTxType, + // TODO rm + txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], }); } - 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(), - }, - }); - } + tradeData.push({ + tx: quoteResponse.trade, + type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, + assetsFiatValues: { + sending: quoteResponse.sentAmount?.valueInCurrency?.toString(), + receiving: quoteResponse.toTokenAmount?.valueInCurrency?.toString(), + }, + txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], + }); + // } const transactionParams = await getAddTransactionBatchParams({ messenger, tradeData, - quote: quoteResponse.quote, requireApproval, isDelegatedAccount, + // isBatchSell: Boolean(batchSimulationResponse), + gasIncluded: quoteResponses[0].quote.gasIncluded, + gasIncluded7702: quoteResponses[0].quote.gasIncluded7702, + gasSponsored: quoteResponses[0].quote.gasSponsored, }); const { approvalMeta, tradeMeta } = await addTransactionBatch( diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index d968dc339b..9d713e7639 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 { + // BatchSimulationResponse, BridgeClientId, QuoteMetadata, QuoteResponse, @@ -78,6 +79,7 @@ export type SubmitStepResult = * The parameters for the submission flow */ export type SubmitStrategyParams = { + // batchSimulationResponse?: BatchSimulationResponse; addTransactionBatchFn: TransactionController['addTransactionBatch']; isBridgeTx: boolean; isDelegatedAccount: boolean; diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index ec0c22da01..0686dce80e 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -15,7 +15,7 @@ import type { Trade, RequiredEventContextFromClient, UnifiedSwapBridgeEventName, - ObtainGaslessBatchResponse, + BatchSimulationResponse, } from '@metamask/bridge-controller'; import type { GetGasFeeState } from '@metamask/gas-fee-controller'; import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; @@ -117,7 +117,7 @@ export type SubmitTxParams< * Contains transaction data for the quotes, * provided by the obtainGaslessBatch API */ - gaslessBatchSellResponse?: ObtainGaslessBatchResponse; + batchSimulationResponse?: BatchSimulationResponse; } & QRPType; /** @@ -135,7 +135,7 @@ export type SubmitTxLegacyParams = Parameters< abTests?: SubmitTxParams['abTests'], activeAbTests?: SubmitTxParams['activeAbTests'], tokenSecurityTypeDestination?: SubmitTxParams['tokenSecurityTypeDestination'], - gaslessBatchSellResponse?: SubmitTxParams['gaslessBatchSellResponse'], + batchSimulationResponse?: SubmitTxParams['batchSimulationResponse'], ) => void >; diff --git a/packages/bridge-status-controller/src/utils/gas.test.ts b/packages/bridge-status-controller/src/utils/gas.test.ts index fe1cb9f3bf..2a424c447f 100644 --- a/packages/bridge-status-controller/src/utils/gas.test.ts +++ b/packages/bridge-status-controller/src/utils/gas.test.ts @@ -7,7 +7,7 @@ import type { FeeMarketGasFeeEstimates } from '@metamask/transaction-controller' import { GasFeeEstimateLevel } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; -import { calculateGasFees, getTxGasEstimates } from './transaction'; +import { appendGasFees } from './transaction'; // Mock data const mockTxGasFeeEstimates = { @@ -34,7 +34,7 @@ const mockMessengerCall = jest.fn(); const mockMessenger = { call: mockMessengerCall }; describe('gas calculation utils', () => { - describe('getTxGasEstimates', () => { + describe.skip('getTxGasEstimates', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -145,7 +145,7 @@ describe('gas calculation utils', () => { }); }); - describe('calculateGasFees', () => { + describe.skip('calculateGasFees', () => { const mockTrade: TxData = { chainId: 1, gasLimit: 1231, @@ -156,7 +156,7 @@ describe('gas calculation utils', () => { }; it('should txFee when provided', async () => { - const result = await calculateGasFees( + const result = await appendGasFees( null as never, mockTrade, 'mainnet', @@ -198,7 +198,7 @@ describe('gas calculation utils', () => { }, }, }); - const result = await calculateGasFees( + const result = await appendGasFees( { 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 daf730137c..56e6ffcb7d 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -8,6 +8,7 @@ import { import type { QuoteMetadata, QuoteResponse, + SimulatedGasFeeLimits, TxData, } from '@metamask/bridge-controller'; import { @@ -26,7 +27,6 @@ import { getAddTransactionBatchParams, findAndUpdateTransactionsInBatch, waitForTxConfirmation, - toBatchTxParams, } from './transaction'; describe('Bridge Status Controller Transaction Utils', () => { @@ -1664,26 +1664,6 @@ describe('Bridge Status Controller Transaction Utils', () => { }); }); - describe('toBatchTxParams', () => { - it('should return params without gas if gasFees are undefined', () => { - const mockTrade = { - chainId: 1, - gasLimit: 1231, - to: '0x1', - data: '0x1', - from: '0x1', - value: '0x1', - }; - const result = toBatchTxParams(mockTrade as TxData); - expect(result).toStrictEqual({ - data: '0x1', - from: '0x1', - to: '0x1', - value: '0x1', - }); - }); - }); - describe('getAddTransactionBatchParams', () => { let mockMessagingSystem: BridgeStatusControllerMessenger; const mockAccount = { @@ -1700,6 +1680,7 @@ describe('Bridge Status Controller Transaction Utils', () => { gasIncluded7702?: boolean; includeApproval?: boolean; includeResetApproval?: boolean; + txFee?: SimulatedGasFeeLimits; } = {}, ): QuoteResponse & QuoteMetadata => ({ @@ -1725,7 +1706,6 @@ describe('Bridge Status Controller Transaction Utils', () => { [FeeType.METABRIDGE]: { amount: '100000000000000000', }, - txFee: '50000000000000000', }, gasIncluded: overrides.gasIncluded ?? false, gasIncluded7702: overrides.gasIncluded7702 ?? false, @@ -1815,19 +1795,27 @@ describe('Bridge Status Controller Transaction Utils', () => { const mockQuoteResponse = createMockQuoteResponse({ gasIncluded7702: true, includeApproval: true, + txFee: { + maxFeePerGas: '50000000000000000', + maxPriorityFeePerGas: '10000000000000000', + }, }); const result = await getAddTransactionBatchParams({ - quote: mockQuoteResponse.quote, + gasIncluded: mockQuoteResponse.quote.gasIncluded, + gasIncluded7702: mockQuoteResponse.quote.gasIncluded7702, + gasSponsored: mockQuoteResponse.quote.gasSponsored, messenger: mockMessagingSystem, tradeData: [ { tx: mockQuoteResponse.approval as TxData, type: TransactionType.bridgeApproval, + txFee: mockQuoteResponse.quote.feeData[FeeType.TX_FEE], }, { tx: mockQuoteResponse.trade, type: TransactionType.bridge, + txFee: mockQuoteResponse.quote.feeData[FeeType.TX_FEE], }, ], isDelegatedAccount: false, @@ -1848,7 +1836,9 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quote: mockQuoteResponse.quote, + gasIncluded: mockQuoteResponse.quote.gasIncluded, + gasIncluded7702: mockQuoteResponse.quote.gasIncluded7702, + gasSponsored: mockQuoteResponse.quote.gasSponsored, messenger: mockMessagingSystem, tradeData: [ { @@ -1873,7 +1863,9 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quote: mockQuoteResponse.quote, + gasIncluded: mockQuoteResponse.quote.gasIncluded, + gasIncluded7702: mockQuoteResponse.quote.gasIncluded7702, + gasSponsored: mockQuoteResponse.quote.gasSponsored, messenger: mockMessagingSystem, tradeData: [ { @@ -1899,7 +1891,9 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quote: mockQuoteResponse.quote, + gasIncluded: mockQuoteResponse.quote.gasIncluded, + gasIncluded7702: mockQuoteResponse.quote.gasIncluded7702, + gasSponsored: mockQuoteResponse.quote.gasSponsored, messenger: mockMessagingSystem, tradeData: [ { @@ -1927,16 +1921,20 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quote: mockQuoteResponse.quote, + gasIncluded: mockQuoteResponse.quote.gasIncluded, + gasIncluded7702: mockQuoteResponse.quote.gasIncluded7702, + gasSponsored: mockQuoteResponse.quote.gasSponsored, messenger: mockMessagingSystem, tradeData: [ { tx: mockQuoteResponse.resetApproval as TxData, type: TransactionType.bridgeApproval, + txFee: mockQuoteResponse.quote.feeData[FeeType.TX_FEE], }, { tx: mockQuoteResponse.trade, type: TransactionType.bridge, + txFee: mockQuoteResponse.quote.feeData[FeeType.TX_FEE], }, ], isDelegatedAccount: false, @@ -1957,7 +1955,9 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quote: mockQuoteResponse.quote, + gasIncluded: mockQuoteResponse.quote.gasIncluded, + gasIncluded7702: mockQuoteResponse.quote.gasIncluded7702, + gasSponsored: mockQuoteResponse.quote.gasSponsored, messenger: mockMessagingSystem, tradeData: [ { @@ -1978,7 +1978,9 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quote: mockQuoteResponse.quote, + gasIncluded: mockQuoteResponse.quote.gasIncluded, + gasIncluded7702: mockQuoteResponse.quote.gasIncluded7702, + gasSponsored: mockQuoteResponse.quote.gasSponsored, messenger: mockMessagingSystem, tradeData: [ { @@ -1999,7 +2001,9 @@ describe('Bridge Status Controller Transaction Utils', () => { }); const result = await getAddTransactionBatchParams({ - quote: mockQuoteResponse.quote, + gasIncluded: mockQuoteResponse.quote.gasIncluded, + gasIncluded7702: mockQuoteResponse.quote.gasIncluded7702, + gasSponsored: mockQuoteResponse.quote.gasSponsored, messenger: mockMessagingSystem, tradeData: [ { @@ -2017,6 +2021,10 @@ describe('Bridge Status Controller Transaction Utils', () => { it('should enable 7702 but include gas fields when isDelegatedAccount is true and gasIncluded7702 is false', async () => { const mockQuoteResponse = createMockQuoteResponse({ gasIncluded7702: false, + txFee: { + maxFeePerGas: '1212', + maxPriorityFeePerGas: '3434', + }, }); const mockMessenger = createMockMessagingSystem({ @@ -2030,12 +2038,15 @@ describe('Bridge Status Controller Transaction Utils', () => { const callSpy = jest.spyOn(mockMessenger, 'call'); const result = await getAddTransactionBatchParams({ - quote: mockQuoteResponse.quote, + gasIncluded: mockQuoteResponse.quote.gasIncluded, + gasIncluded7702: mockQuoteResponse.quote.gasIncluded7702, + gasSponsored: mockQuoteResponse.quote.gasSponsored, messenger: mockMessenger, tradeData: [ { tx: mockQuoteResponse.trade as TxData, type: TransactionType.bridge, + txFee: mockQuoteResponse.quote.feeData[FeeType.TX_FEE], }, ], isDelegatedAccount: true, @@ -2055,9 +2066,6 @@ describe('Bridge Status Controller Transaction Utils', () => { "NetworkController:findNetworkClientIdByChainId", "0x1", ], - [ - "GasFeeController:getState", - ], [ "TransactionController:estimateGasFee", { @@ -2066,7 +2074,7 @@ describe('Bridge Status Controller Transaction Utils', () => { "transactionParams": { "data": "0xbridgeData", "from": "0xUserAddress", - "gas": "21000", + "gas": "0x5208", "to": "0xBridgeContract", "value": "0x1000", }, @@ -2090,12 +2098,15 @@ describe('Bridge Status Controller Transaction Utils', () => { const callSpy = jest.spyOn(mockMessagingSystem, 'call'); const result = await getAddTransactionBatchParams({ - quote: mockQuoteResponse.quote, + gasIncluded: mockQuoteResponse.quote.gasIncluded, + gasIncluded7702: mockQuoteResponse.quote.gasIncluded7702, + gasSponsored: mockQuoteResponse.quote.gasSponsored, messenger: mockMessagingSystem, tradeData: [ { tx: mockQuoteResponse.trade as TxData, type: TransactionType.bridge, + txFee: mockQuoteResponse.quote.feeData[FeeType.TX_FEE], }, ], isDelegatedAccount: true, diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 0fead6def1..0145e2ba1d 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -5,14 +5,18 @@ import { formatChainIdToHex, BRIDGE_PREFERRED_GAS_ESTIMATE, } from '@metamask/bridge-controller'; -import type { Quote, QuoteResponse, TxData } from '@metamask/bridge-controller'; +import type { + GaslessProperties, + QuoteResponse, + SimulatedGasFeeLimits, + TxData, +} from '@metamask/bridge-controller'; import { toHex } from '@metamask/controller-utils'; import { TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; import type { - BatchTransactionParams, IsAtomicBatchSupportedResultEntry, TransactionController, TransactionMeta, @@ -20,7 +24,6 @@ import type { TransactionParams, } from '@metamask/transaction-controller'; import { createProjectLogger, Hex } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; import { APPROVAL_DELAY_MS } from '../constants'; import type { BridgeStatusControllerMessenger } from '../types'; @@ -35,96 +38,78 @@ const isTradeTx = (type: TransactionType) => export const isCrossChainTx = (type: TransactionType) => isTradeTx(type) || isApprovalTx(type); -export const getGasFeeEstimates = async ( - messenger: BridgeStatusControllerMessenger, - args: Parameters[0], -): Promise<{ maxFeePerGas?: string; maxPriorityFeePerGas?: string }> => { - const { estimates } = await messenger.call( - 'TransactionController:estimateGasFee', - args, - ); - if ( - BRIDGE_PREFERRED_GAS_ESTIMATE in estimates && - typeof estimates[BRIDGE_PREFERRED_GAS_ESTIMATE] === 'object' && - 'maxFeePerGas' in estimates[BRIDGE_PREFERRED_GAS_ESTIMATE] && - 'maxPriorityFeePerGas' in estimates[BRIDGE_PREFERRED_GAS_ESTIMATE] - ) { - return estimates[BRIDGE_PREFERRED_GAS_ESTIMATE]; - } - return {}; -}; - /** - * Get the gas fee estimates for a transaction + * Appends the gas fee estimates for a transaction * * @param messenger - The messenger for the gas fee estimates - * @param estimateGasFeeParams - The parameters for the {@link TransactionController.estimateGasFee} method - + * @param trade - the trade data to append gas fees to + * @param trade.chainId - ignored, use chainId instead + * @param trade.gasLimit - the gas limit to use for the gas fee estimates + * @param networkClientId - the network client ID to use for the gas fee estimates + * @param chainId - the chain ID to use for the gas fee estimates + * @param isGasIncluded7702 - whether the gas is included via 7702 + * @param simulatedGasFeeLimits - either the txFee from the quote or the simulated gas fee limits for the batch sell * @returns The gas fee estimates for the transaction */ -export const getTxGasEstimates = async ( - messenger: BridgeStatusControllerMessenger, - estimateGasFeeParams: Parameters[0], -) => { - const { gasFeeEstimates } = messenger.call('GasFeeController:getState'); - const estimatedBaseFee = - 'estimatedBaseFee' in gasFeeEstimates - ? gasFeeEstimates.estimatedBaseFee - : '0'; - - // Get transaction's 1559 gas fee estimates - const { maxFeePerGas, maxPriorityFeePerGas } = await getGasFeeEstimates( - messenger, - estimateGasFeeParams, - ); - - /** - * @deprecated this is unused - */ - const baseAndPriorityFeePerGas = maxPriorityFeePerGas - ? new BigNumber(estimatedBaseFee, 10) - .times(10 ** 9) - .plus(maxPriorityFeePerGas, 16) - : undefined; - - return { - baseAndPriorityFeePerGas, - maxFeePerGas, - maxPriorityFeePerGas, - }; -}; - -export const calculateGasFees = async ( +const appendGasFees = async ( messenger: BridgeStatusControllerMessenger, - { chainId: _, gasLimit, ...trade }: TxData, + { chainId: tradeChainId, gasLimit, ...trade }: TxData, networkClientId: string, chainId: Hex, - txFee?: { maxFeePerGas: string; maxPriorityFeePerGas: string }, + isGasIncluded7702: boolean, + simulatedGasFeeLimits?: SimulatedGasFeeLimits, ) => { - if (txFee) { - return { ...txFee, gas: gasLimit?.toString() }; - } - const transactionParams = { + const normalizedTrade = { ...trade, - gas: gasLimit?.toString(), - data: trade.data as `0x${string}`, to: trade.to as `0x${string}`, + from: trade.from as `0x${string}`, value: trade.value as `0x${string}`, + data: trade.data as `0x${string}`, }; - const { maxFeePerGas, maxPriorityFeePerGas } = await getTxGasEstimates( - messenger, + if (isGasIncluded7702) { + return normalizedTrade; + } + const transactionParams = { + ...trade, + // Only add gasLimit and gas if they're valid (not undefined/null/zero) + gas: gasLimit ? toHex(gasLimit) : undefined, + ...normalizedTrade, + }; + + if (simulatedGasFeeLimits) { + return { + ...transactionParams, + maxFeePerGas: toHex(simulatedGasFeeLimits.maxFeePerGas), + maxPriorityFeePerGas: toHex(simulatedGasFeeLimits.maxPriorityFeePerGas), + }; + } + + // Get transaction's 1559 gas fee estimates + const { estimates } = await messenger.call( + 'TransactionController:estimateGasFee', { transactionParams, networkClientId, chainId, }, ); - const maxGasLimit = toHex(transactionParams.gas ?? 0); + + let gasFeeEstimates: Partial> = { + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + }; + if ( + BRIDGE_PREFERRED_GAS_ESTIMATE in estimates && + typeof estimates[BRIDGE_PREFERRED_GAS_ESTIMATE] === 'object' && + 'maxFeePerGas' in estimates[BRIDGE_PREFERRED_GAS_ESTIMATE] + ) { + gasFeeEstimates = estimates[BRIDGE_PREFERRED_GAS_ESTIMATE]; + } return { - maxFeePerGas, - maxPriorityFeePerGas, - gas: maxGasLimit, + ...transactionParams, + maxFeePerGas: gasFeeEstimates.maxFeePerGas, + maxPriorityFeePerGas: gasFeeEstimates.maxPriorityFeePerGas, }; }; @@ -315,86 +300,30 @@ export const waitForTxConfirmation = async ( } }; -export const toBatchTxParams = ( - { chainId, gasLimit, ...trade }: TxData, - gasFees?: { - maxFeePerGas?: string; - maxPriorityFeePerGas?: string; - gas?: string; - }, -): BatchTransactionParams => { - const params = { - ...trade, - data: trade.data as Hex, - to: trade.to as Hex, - value: trade.value as Hex, - }; - if (!gasFees) { - return params; - } - - const { maxFeePerGas, maxPriorityFeePerGas, gas } = gasFees; - return { - ...params, - gas: toHex(gas ?? 0), - maxFeePerGas: toHex(maxFeePerGas ?? 0), - maxPriorityFeePerGas: toHex(maxPriorityFeePerGas ?? 0), - }; +export type TradeWithMetadata = { + tx: TxData & Partial; + type: TransactionType; + assetsFiatValues?: { sending?: string; receiving?: string }; + txFee?: SimulatedGasFeeLimits; }; -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, tradeData, requireApproval = false, - ...gasIncludedArgs -}: { + // isBatchSell, + gasIncluded7702, + gasSponsored, + gasIncluded, + isDelegatedAccount, +}: GaslessProperties & { messenger: BridgeStatusControllerMessenger; - tradeData: { - tx: TxData; - type: TransactionType; - assetsFiatValues?: { sending?: string; receiving?: string }; - }[]; + tradeData: TradeWithMetadata[]; requireApproval?: boolean; -} & Parameters[0]): Promise< - Parameters[0] -> => { - const { - isGasFeeIncluded, - isGasFeeSponsored, - disable7702, - skipGasFields, - txFee, - } = getGaslessParams(gasIncludedArgs); - - const trade = tradeData[0]?.tx; - const selectedAccount = getAccountByAddress(messenger, trade?.from); + isDelegatedAccount: boolean; +}): Promise[0]> => { + 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', @@ -405,33 +334,49 @@ export const getAddTransactionBatchParams = async ({ const networkClientId = getNetworkClientIdByChainId(messenger, hexChainId); const transactions: TransactionBatchSingleRequest[] = await Promise.all( - tradeData.map(async ({ type, assetsFiatValues, tx }) => { - const gasFees = skipGasFields - ? undefined - : await calculateGasFees( - messenger, - tx, - networkClientId, - hexChainId, - txFee, - ); + tradeData.map(async ({ type, assetsFiatValues, tx, txFee }) => { + // TODO this means when gasIncluded7702, we don't use the simulated gas fee limits? + // We only use them when gasIncluded is true + // Maybe we should always pass txFee when defined + const txParams = await appendGasFees( + messenger, + tx, + networkClientId, + hexChainId, + Boolean(gasIncluded7702), + txFee, + ); return { type, - params: toBatchTxParams(tx, gasFees), + params: txParams, assetsFiatValues, }; }), - ).then((txs) => txs); + ); return { - disable7702, - isGasFeeIncluded, - isGasFeeSponsored, + /** + * Disable 7702 batching when the quote can't be submitted via 7702 + */ + 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, + isGasFeeSponsored: Boolean(gasSponsored), + /** + * Gas fields should be omitted only when gas is sponsored via 7702 + */ + isGasFeeIncluded: Boolean(gasIncluded7702), networkClientId, requireApproval, origin: 'metamask', from: selectedAccount.address as Hex, transactions, + // atomic: !isBatchSell, }; }; @@ -548,45 +493,6 @@ export const addTransactionBatch = async ( return { approvalMeta, tradeMeta }; }; -// TODO rename -const getGasFeesForSubmission = async ( - messenger: BridgeStatusControllerMessenger, - transactionParams: TransactionParams, - networkClientId: string, - chainId: Hex, - txFee?: { maxFeePerGas: string; maxPriorityFeePerGas: string }, -): Promise<{ - maxFeePerGas?: string; // Hex - maxPriorityFeePerGas?: string; // Hex - gas?: Hex; -}> => { - const { gas } = transactionParams; - // If txFee is provided (gasIncluded case), use the quote's gas fees - // Convert to hex since txFee values from the quote are decimal strings - if (txFee) { - return { - maxFeePerGas: toHex(txFee.maxFeePerGas), - maxPriorityFeePerGas: toHex(txFee.maxPriorityFeePerGas), - gas: gas ? toHex(gas) : undefined, - }; - } - - const { maxFeePerGas, maxPriorityFeePerGas } = await getTxGasEstimates( - messenger, - { - transactionParams, - chainId, - networkClientId, - }, - ); - - return { - maxFeePerGas, - maxPriorityFeePerGas, - gas: gas ? toHex(gas) : undefined, - }; -}; - /** * Submits an EVM transaction to the TransactionController * @@ -633,31 +539,15 @@ export const submitEvmTransaction = async ({ type: transactionType, origin: 'metamask', }; - // Exclude gasLimit from trade to avoid type issues (it can be null) - const { gasLimit: tradeGasLimit, ...tradeWithoutGasLimit } = trade; - - const transactionParams: Parameters< - TransactionController['addTransaction'] - >[0] = { - ...tradeWithoutGasLimit, - chainId: hexChainId, - // Only add gasLimit and gas if they're valid (not undefined/null/zero) - ...(tradeGasLimit && - tradeGasLimit !== 0 && { - gasLimit: tradeGasLimit.toString(), - gas: tradeGasLimit.toString(), - }), - }; - const transactionParamsWithMaxGas: TransactionParams = { - ...transactionParams, - ...(await getGasFeesForSubmission( - messenger, - transactionParams, - networkClientId, - hexChainId, - txFee, - )), - }; + + const transactionParamsWithMaxGas: TransactionParams = await appendGasFees( + messenger, + trade, + networkClientId, + hexChainId, + false, + txFee, + ); return await addTransaction( messenger, From 5d428b0fa22eb7820608d57e7848b3597f6465ef Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 11 May 2026 17:53:33 -0700 Subject: [PATCH 4/9] feat: BatchSell submission --- .../bridge-status-controller.test.ts.snap | 2 + .../src/strategy/batch-strategy.ts | 100 +++++++++--------- .../src/strategy/types.ts | 4 +- .../src/utils/transaction.ts | 5 +- 4 files changed, 57 insertions(+), 54 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 a7b0771738..3302f77937 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 @@ -1225,6 +1225,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac [ [ { + "atomic": true, "disable7702": true, "from": "0xaccount1", "isGasFeeIncluded": false, @@ -3834,6 +3835,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti [ [ { + "atomic": true, "disable7702": true, "from": "0xaccount1", "isGasFeeIncluded": false, diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts index f988a68b81..2544051833 100644 --- a/packages/bridge-status-controller/src/strategy/batch-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -25,7 +25,7 @@ export async function* submitBatchHandler( isBridgeTx, addTransactionBatchFn, isDelegatedAccount, - // batchSimulationResponse, + batchSimulationResponse, } = args; const quoteRequestIndex = 0; @@ -36,66 +36,66 @@ export async function* submitBatchHandler( const tradeData: TradeWithMetadata[] = []; - // if (batchSimulationResponse) { - // const { transactions } = batchSimulationResponse; + if (batchSimulationResponse) { + const { transactions } = batchSimulationResponse; - // batchSimulationResponse.transactions.forEach( - // ({ assetId, type, maxFeePerGas, maxPriorityFeePerGas, ...tx }) => { - // const quoteResponse = quoteResponses.find( - // ({ quote }) => quote.srcAsset.assetId === transactions[0].assetId, - // ); - // tradeData.push({ - // tx, - // type: - // type === 'swap' - // ? TransactionType.swap - // : TransactionType.swapApproval, - // // If there is no matching quote response, these will be undefined - // assetsFiatValues: { - // sending: quoteResponse?.sentAmount?.valueInCurrency?.toString(), - // receiving: - // quoteResponse?.toTokenAmount?.valueInCurrency?.toString(), - // }, - // txFee: { maxFeePerGas, maxPriorityFeePerGas }, - // }); - // }, - // ); - // } else { - const quoteResponse = quoteResponses[quoteRequestIndex]; - if (quoteResponse.resetApproval) { - tradeData.push({ - tx: quoteResponse.resetApproval, - type: approvalTxType, - // TODO for regular 7702, shoudl txFee be appended to both approval and trade? - // I think it covers both - txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], - }); - } - if (quoteResponse.approval) { + batchSimulationResponse.transactions.forEach( + ({ assetId, type, maxFeePerGas, maxPriorityFeePerGas, ...tx }) => { + const quoteResponse = quoteResponses.find( + ({ quote }) => quote.srcAsset.assetId === transactions[0].assetId, + ); + tradeData.push({ + tx, + type: + type === 'swap' + ? TransactionType.swap + : TransactionType.swapApproval, + // If there is no matching quote response, these will be undefined + assetsFiatValues: { + sending: quoteResponse?.sentAmount?.valueInCurrency?.toString(), + receiving: + quoteResponse?.toTokenAmount?.valueInCurrency?.toString(), + }, + txFee: { maxFeePerGas, maxPriorityFeePerGas }, + }); + }, + ); + } else { + const quoteResponse = quoteResponses[quoteRequestIndex]; + if (quoteResponse.resetApproval) { + tradeData.push({ + tx: quoteResponse.resetApproval, + type: approvalTxType, + // TODO for regular 7702, shoudl txFee be appended to both approval and trade? + // I think it covers both + txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], + }); + } + if (quoteResponse.approval) { + tradeData.push({ + tx: quoteResponse.approval, + type: approvalTxType, + // TODO rm + txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], + }); + } tradeData.push({ - tx: quoteResponse.approval, - type: approvalTxType, - // TODO rm + tx: quoteResponse.trade, + type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, + assetsFiatValues: { + sending: quoteResponse.sentAmount?.valueInCurrency?.toString(), + receiving: quoteResponse.toTokenAmount?.valueInCurrency?.toString(), + }, txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], }); } - tradeData.push({ - tx: quoteResponse.trade, - type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, - assetsFiatValues: { - sending: quoteResponse.sentAmount?.valueInCurrency?.toString(), - receiving: quoteResponse.toTokenAmount?.valueInCurrency?.toString(), - }, - txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], - }); - // } const transactionParams = await getAddTransactionBatchParams({ messenger, tradeData, requireApproval, isDelegatedAccount, - // isBatchSell: Boolean(batchSimulationResponse), + isBatchSell: Boolean(batchSimulationResponse), gasIncluded: quoteResponses[0].quote.gasIncluded, gasIncluded7702: quoteResponses[0].quote.gasIncluded7702, gasSponsored: quoteResponses[0].quote.gasSponsored, diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index 9d713e7639..b3dffbf311 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -1,6 +1,6 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { - // BatchSimulationResponse, + BatchSimulationResponse, BridgeClientId, QuoteMetadata, QuoteResponse, @@ -79,7 +79,7 @@ export type SubmitStepResult = * The parameters for the submission flow */ export type SubmitStrategyParams = { - // batchSimulationResponse?: BatchSimulationResponse; + batchSimulationResponse?: BatchSimulationResponse; addTransactionBatchFn: TransactionController['addTransactionBatch']; isBridgeTx: boolean; isDelegatedAccount: boolean; diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 0145e2ba1d..22a20d893e 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -311,7 +311,7 @@ export const getAddTransactionBatchParams = async ({ messenger, tradeData, requireApproval = false, - // isBatchSell, + isBatchSell, gasIncluded7702, gasSponsored, gasIncluded, @@ -321,6 +321,7 @@ export const getAddTransactionBatchParams = async ({ tradeData: TradeWithMetadata[]; requireApproval?: boolean; isDelegatedAccount: boolean; + isBatchSell?: boolean; }): Promise[0]> => { const trade = tradeData[0].tx; const selectedAccount = getAccountByAddress(messenger, trade.from); @@ -376,7 +377,7 @@ export const getAddTransactionBatchParams = async ({ origin: 'metamask', from: selectedAccount.address as Hex, transactions, - // atomic: !isBatchSell, + atomic: !isBatchSell, }; }; From 4671788ad82c5abfc742c48f7df51c3d6803d446 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 12 May 2026 08:37:54 -0700 Subject: [PATCH 5/9] wip --- .../src/strategy/batch-strategy.ts | 95 ++++++++++++------- .../src/strategy/types.ts | 3 + .../src/utils/transaction.ts | 20 ++-- 3 files changed, 76 insertions(+), 42 deletions(-) diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts index 2544051833..7be4b66959 100644 --- a/packages/bridge-status-controller/src/strategy/batch-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -3,6 +3,7 @@ import { TransactionType } from '@metamask/transaction-controller'; import { addTransactionBatch, + generateBatchId, getAddTransactionBatchParams, TradeWithMetadata, } from '../utils/transaction'; @@ -36,30 +37,45 @@ export async function* submitBatchHandler( const tradeData: TradeWithMetadata[] = []; + const batchId = generateBatchId(); + if (batchSimulationResponse) { const { transactions } = batchSimulationResponse; - batchSimulationResponse.transactions.forEach( - ({ assetId, type, maxFeePerGas, maxPriorityFeePerGas, ...tx }) => { - const quoteResponse = quoteResponses.find( - ({ quote }) => quote.srcAsset.assetId === transactions[0].assetId, - ); - tradeData.push({ - tx, - type: - type === 'swap' - ? TransactionType.swap - : TransactionType.swapApproval, - // If there is no matching quote response, these will be undefined - assetsFiatValues: { - sending: quoteResponse?.sentAmount?.valueInCurrency?.toString(), - receiving: - quoteResponse?.toTokenAmount?.valueInCurrency?.toString(), + for (const [index, transaction] of transactions.entries()) { + const { assetId, type, maxFeePerGas, maxPriorityFeePerGas, ...tx } = + transaction; + const quoteResponse = quoteResponses.find( + ({ quote }) => quote.srcAsset.assetId === transactions[0].assetId, + ); + tradeData.push({ + tx, + type: + type === 'swap' ? TransactionType.swap : TransactionType.swapApproval, + // If there is no matching quote response, these will be undefined + assetsFiatValues: + type === 'swap' + ? { + sending: quoteResponse?.sentAmount?.valueInCurrency?.toString(), + receiving: + quoteResponse?.toTokenAmount?.valueInCurrency?.toString(), + } + : undefined, + txFee: { maxFeePerGas, maxPriorityFeePerGas }, + }); + if (type === 'swap') { + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: tx.data, + quoteRequestIndex: index, + tradeTxData: quoteResponses[index].trade.data, + approvalTxData: quoteResponses[index].approval?.data, + batchId, }, - txFee: { maxFeePerGas, maxPriorityFeePerGas }, - }); - }, - ); + }; + } + } } else { const quoteResponse = quoteResponses[quoteRequestIndex]; if (quoteResponse.resetApproval) { @@ -99,6 +115,7 @@ export async function* submitBatchHandler( gasIncluded: quoteResponses[0].quote.gasIncluded, gasIncluded7702: quoteResponses[0].quote.gasIncluded7702, gasSponsored: quoteResponses[0].quote.gasSponsored, + batchId, }); const { approvalMeta, tradeMeta } = await addTransactionBatch( @@ -107,22 +124,28 @@ export async function* submitBatchHandler( transactionParams, ); - yield { - type: SubmitStep.SetTradeMeta, - payload: { tradeMeta, quoteRequestIndex }, - }; + if (!tradeMeta) { + return; + } + + if (tradeMeta) { + yield { + type: SubmitStep.SetTradeMeta, + payload: { tradeMeta, quoteRequestIndex }, + }; - yield { - type: SubmitStep.AddHistoryItem, - payload: { - historyKey: tradeMeta.id, - approvalTxId: approvalMeta?.id, - bridgeTxMeta: { - id: tradeMeta.id, - hash: tradeMeta.hash, - batchId: tradeMeta.batchId, + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: tradeMeta.id, + approvalTxId: approvalMeta?.id, + bridgeTxMeta: { + id: tradeMeta.id, + hash: tradeMeta.hash, + batchId: tradeMeta.batchId, + }, + quoteRequestIndex, }, - quoteRequestIndex, - }, - }; + }; + } } diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index b3dffbf311..cfaec745b5 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -38,7 +38,10 @@ export type SubmitStepResult = 'approvalTxId' | 'bridgeTxMeta' | 'originalTransactionId' | 'actionId' > & { historyKey: string; + tradeTxData?: string; + approvalTxData?: string; quoteRequestIndex?: number; + batchId?: string; }; } | { diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 22a20d893e..3b143ed815 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -198,6 +198,7 @@ export const addTransaction = async ( }; export const generateActionId = () => (Date.now() + Math.random()).toString(); +export const generateBatchId = () => toHex(Date.now() + Math.random()); /** * Adds a synthetic transaction to the TransactionController to display pending intent orders in the UI @@ -316,12 +317,14 @@ export const getAddTransactionBatchParams = async ({ gasSponsored, gasIncluded, isDelegatedAccount, + batchId, }: GaslessProperties & { messenger: BridgeStatusControllerMessenger; tradeData: TradeWithMetadata[]; requireApproval?: boolean; isDelegatedAccount: boolean; isBatchSell?: boolean; + batchId?: Hex; }): Promise[0]> => { const trade = tradeData[0].tx; const selectedAccount = getAccountByAddress(messenger, trade.from); @@ -378,6 +381,7 @@ export const getAddTransactionBatchParams = async ({ from: selectedAccount.address as Hex, transactions, atomic: !isBatchSell, + batchId, }; }; @@ -460,24 +464,28 @@ export const findAndUpdateTransactionsInBatch = ({ export const addTransactionBatch = async ( messenger: BridgeStatusControllerMessenger, addTransactionBatchFn: TransactionController['addTransactionBatch'], - ...args: Parameters + args: Parameters[0], ) => { const txDataByType = { - [TransactionType.bridgeApproval]: args[0].transactions.find( + [TransactionType.bridgeApproval]: args.transactions.find( ({ type }) => type === TransactionType.bridgeApproval, )?.params.data, - [TransactionType.swapApproval]: args[0].transactions.find( + [TransactionType.swapApproval]: args.transactions.find( ({ type }) => type === TransactionType.swapApproval, )?.params.data, - [TransactionType.bridge]: args[0].transactions.find( + [TransactionType.bridge]: args.transactions.find( ({ type }) => type === TransactionType.bridge, )?.params.data, - [TransactionType.swap]: args[0].transactions.find( + [TransactionType.swap]: args.transactions.find( ({ type }) => type === TransactionType.swap, )?.params.data, }; - const { batchId } = await addTransactionBatchFn(...args); + const { batchId } = await addTransactionBatchFn(args); + + if (!args.atomic) { + return { batchId }; + } const { approvalMeta, tradeMeta } = findAndUpdateTransactionsInBatch({ messenger, From 9b264ebd6870bff0be1186edbf05cfc1ade2c915 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 12 May 2026 08:44:08 -0700 Subject: [PATCH 6/9] test wip --- .../bridge-status-controller.test.ts.snap | 150 ++++++++++ .../src/bridge-status-controller.test.ts | 273 ++++++++++++++++++ 2 files changed, 423 insertions(+) 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 3302f77937..c637c4b116 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 @@ -448,6 +448,156 @@ exports[`BridgeStatusController startPollingForBridgeTxStatus stops polling when ] `; +exports[`BridgeStatusController submitTx: EVM batch sell (swap) should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit) 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM batch sell (swap) should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit) 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567891.456", + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 0, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Main View", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "0", + "amountSentInUsd": undefined, + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + "txFee": { + "maxFeePerGas": "1395348", + "maxPriorityFeePerGas": "1000001", + }, + }, + "gasIncluded": true, + "gasIncluded7702": false, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting base approval 1`] = ` { "chainId": "0xa4b1", 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 776e013f5c..a9ac4dae83 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -4612,6 +4612,279 @@ describe('BridgeStatusController', () => { }); }); + describe('submitTx: EVM batch sell (swap)', () => { + const mockBatchQuoteResponse = { + ...getMockQuote(), + quote: { + ...getMockQuote(), + srcChainId: 42161, + destChainId: 42161, + gasIncluded7702: true, + gasIncluded: true, + }, + estimatedProcessingTimeInSeconds: 0, + sentAmount: { amount: '1.234', valueInCurrency: '2.00', usd: '1.01' }, + toTokenAmount: { + amount: '1.5', + valueInCurrency: '2.9999', + usd: '0.134214', + }, + minToTokenAmount: { + amount: '1.425', + valueInCurrency: '2.85', + usd: '0.127', + }, + totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, + totalMaxNetworkFee: { + amount: '1.234', + valueInCurrency: null, + usd: null, + }, + gasFee: { + effective: { amount: '.00055', valueInCurrency: null, usd: '2.5778' }, + total: { amount: '1.234', valueInCurrency: null, usd: null }, + max: { amount: '1.234', valueInCurrency: null, usd: null }, + }, + adjustedReturn: { valueInCurrency: null, usd: null }, + swapRate: '1.234', + cost: { valueInCurrency: null, usd: null }, + trade: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: 42161, + gasLimit: 21000, + }, + approval: { + from: '0xaccount1', + to: '0xtokenContract', + value: '0x0', + data: '0xapprovalData', + chainId: 42161, + gasLimit: 21000, + }, + } as QuoteResponse & QuoteMetadata; + + const mockEvmTxMeta = { + id: 'test-tx-id', + hash: '0xevmTxHash', + time: 1234567890, + status: 'unapproved', + type: TransactionType.swap, + chainId: '0xa4b1', // 42161 in hex + txParams: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: '0xa4b1', + gasLimit: '0x5208', + }, + }; + + const mockApprovalTxMeta = { + id: 'test-approval-tx-id', + hash: '0xapprovalTxHash', + time: 1234567890, + status: 'unapproved', + type: TransactionType.swapApproval, + chainId: '0xa4b1', // 42161 in hex + txParams: { + from: '0xaccount1', + to: '0xtokenContract', + value: '0x0', + data: '0xapprovalData', + chainId: '0xa4b1', + gasLimit: '0x5208', + }, + }; + + // TODO rm + const mockEstimateGasFeeResult = { + estimates: { + high: { + suggestedMaxFeePerGas: '0x1234', + suggestedMaxPriorityFeePerGas: '0x5678', + }, + }, + }; + let mockMessengerCall: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockMessengerCall = jest.fn(); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567892); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.456); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.457); + mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes + }); + + const setupEventTrackingMocks = (mockCall: jest.Mock) => { + mockCall.mockReturnValueOnce(mockSelectedAccount); + mockCall.mockImplementationOnce(jest.fn()); // track event + mockCall.mockReturnValueOnce([]); // isAtomicBatchSupported + }; + + it('should use quote txFee when gasIncluded is true and STX is off (Max native token swap)', async () => { + setupEventTrackingMocks(mockMessengerCall); + // Setup for single tx path - no gas estimation needed since gasIncluded=true + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + // Skip GasFeeController mock since we use quote's txFee directly + mockMessengerCall.mockResolvedValueOnce({ + transactionMeta: mockEvmTxMeta, + result: Promise.resolve('0xevmTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockEvmTxMeta], + }); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockBatchQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockBatchQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: false, + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + maxFeePerGas: '1395348', // Decimal string from quote + maxPriorityFeePerGas: '1000001', + }, + }, + }, + }, + false, // isStxEnabledOnClient = FALSE (key for this test) + ); + controller.stopAllPolling(); + + const mockCalls = mockMessengerCall.mock.calls; + + // Should use single tx path (addTransactionFn), NOT batch path + const addTransactionCalls = mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ); + expect(addTransactionCalls).toHaveLength(1); + // Should NOT estimate gas (uses quote's txFee instead) + const estimateGasFeeCalls = mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ); + expect(estimateGasFeeCalls).toHaveLength(0); + + // Verify the tx params have hex-converted gas fees from quote + const txParams = addTransactionCalls[0]?.[1]; + expect(txParams.maxFeePerGas).toBe('0x154a94'); // toHex(1395348) + expect(txParams.maxPriorityFeePerGas).toBe('0xf4241'); // toHex(1000001) + expect(txParams.gas).toBe('0x5208'); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, + ); + }); + + it.only('should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit)', async () => { + setupEventTrackingMocks(mockMessengerCall); + // Setup for single tx path - no gas estimation needed since gasIncluded=true + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + // Skip GasFeeController mock since we use quote's txFee directly + mockMessengerCall.mockResolvedValueOnce({ + transactionMeta: mockEvmTxMeta, + result: Promise.resolve('0xevmTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockEvmTxMeta], + }); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockBatchQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockBatchQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + maxFeePerGas: '1395348', // Decimal string from quote + maxPriorityFeePerGas: '1000001', + }, + }, + }, + trade: { + ...(quoteWithoutApproval.trade as TxData), + gasLimit: undefined as never, + }, + sentAmount: { + amount: null as never, + valueInCurrency: null, + usd: null, + }, + }, + false, // isStxEnabledOnClient = FALSE (key for this test) + ); + controller.stopAllPolling(); + + const mockCalls = mockMessengerCall.mock.calls; + + // Should NOT estimate gas (uses quote's txFee instead) + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(0); + expect( + mockCalls.filter( + ([action]) => + action === 'TransactionController:addTransactionBatch', + ), + ).toHaveLength(0); + + // Should use single tx path (addTransactionFn), NOT batch path + const addTransactionCalls = mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ); + expect(addTransactionCalls).toHaveLength(1); + // Verify the tx params have hex-converted gas fees from quote + const txParams = addTransactionCalls[0]?.[1]; + expect(txParams.maxFeePerGas).toBe('0x154a94'); // toHex(1395348) + expect(txParams.maxPriorityFeePerGas).toBe('0xf4241'); // toHex(1000001) + expect(txParams.gas).toBeUndefined(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, + ); + }); + }); + describe('resetAttempts', () => { const defaultState = { txHistory: { From 24c202655a98ee9ceda7c2ee7292f6ae5d7e71d7 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 12 May 2026 10:38:54 -0700 Subject: [PATCH 7/9] fix: undo signature change --- .../bridge-controller/src/utils/validators.ts | 3 +- .../src/bridge-status-controller.ts | 111 ++++++++++-------- .../bridge-status-controller/src/types.ts | 86 -------------- 3 files changed, 65 insertions(+), 135 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index aafb167133..af3f764d80 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { isValidHexAddress, toHex } from '@metamask/controller-utils'; +import { isValidHexAddress } from '@metamask/controller-utils'; import type { Infer } from '@metamask/superstruct'; import { any, @@ -19,7 +19,6 @@ import { pattern, intersection, pick, - coerce, } from '@metamask/superstruct'; import { CaipAssetTypeStruct, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 764eab41e0..9ec2283227 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, + BatchSimulationResponse, } from '@metamask/bridge-controller'; import { isNonEvmChainId, @@ -44,9 +45,6 @@ import type { StartPollingForBridgeTxStatusArgsSerialized, FetchFunction, BridgeHistoryItem, - SubmitTxParams, - SubmitTxLegacyParams, - QuoteResponseParam, } from './types'; import type { BridgeStatusControllerMessenger } from './types'; import { BridgeClientId } from './types'; @@ -966,8 +964,8 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - const tradeTxMetas: TransactionMeta[] = []; + ): Promise => { + let tradeTxMeta!: TransactionMeta; const steps = executeSubmitStrategy(params); @@ -984,7 +982,7 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - // Both legacy and new parameter formats are supported so transform legacy parameters into new parameters if needed - const { - accountAddress, - quoteResponses, - isStxEnabled = false, - quotesReceivedContext, - location = MetaMetricsSwapsEventSource.MainView, - abTests, - activeAbTests, - tokenSecurityTypeDestination, - batchSimulationResponse, - } = typeof params[0] === 'object' - ? params[0] - : ({ - accountAddress: params[0], - quoteResponse: params[1], - // Transform quoteResponse parameter into quoteResponses parameter - quoteResponses: Array.isArray(params[1]) ? params[1] : [params[1]], - isStxEnabled: params[2], - quotesReceivedContext: params[3], - location: params[4], - abTests: params[5], - activeAbTests: params[6], - tokenSecurityTypeDestination: params[7], - batchSimulationResponse: params[8], - } as SubmitTxParams); - + accountAddress: string, + 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, + batchSimulationResponse?: BatchSimulationResponse, + ): 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; @@ -1139,7 +1131,7 @@ export class BridgeStatusController extends StaticIntervalPollingController await this.#executeSubmitStrategy(strategyParams, { @@ -1150,10 +1142,6 @@ export class BridgeStatusController extends StaticIntervalPollingController 1 ? tradeTxMetas : tradeTxMetas[0]; } catch (error) { this.#trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.Failed, @@ -1195,11 +1183,40 @@ export class BridgeStatusController extends StaticIntervalPollingController> => { // TODO add metrics context - const txMetas = await this.submitTx({ - ...params, - quoteResponses: [params.quoteResponse], - }); - return Array.isArray(txMetas) ? txMetas[0] : txMetas; + return await this.submitTx( + params.accountAddress, + params.quoteResponse, + params.isStxEnabled ?? false, + params.quotesReceivedContext, + params.location, + params.abTests, + params.activeAbTests, + params.tokenSecurityTypeDestination, + ); + }; + + submitBatchSell = async (params: { + quoteResponses: (QuoteResponse & QuoteMetadata)[]; + batchSimulationResponse: BatchSimulationResponse; + accountAddress: string; + location?: MetaMetricsSwapsEventSource; + abTests?: Record; + activeAbTests?: { key: string; value: string }[]; + isStxEnabled?: boolean; + quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived]; + tokenSecurityTypeDestination?: string | null; + }): Promise => { + return await this.submitTx( + params.accountAddress, + params.quoteResponses, + params.isStxEnabled ?? false, + params.quotesReceivedContext, + params.location, + params.abTests, + params.activeAbTests, + params.tokenSecurityTypeDestination, + params.batchSimulationResponse, + ); }; readonly #trackPollingStatusUpdatedEvent = ( diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 0686dce80e..6b01432a4b 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -12,10 +12,6 @@ import type { QuoteMetadata, QuoteResponse, MetaMetricsSwapsEventSource, - Trade, - RequiredEventContextFromClient, - UnifiedSwapBridgeEventName, - BatchSimulationResponse, } from '@metamask/bridge-controller'; import type { GetGasFeeState } from '@metamask/gas-fee-controller'; import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; @@ -57,88 +53,6 @@ export type FetchFunction = ( init?: RequestInit, ) => Promise; -type LegacyQuoteResponseParam = { - /** - * A quote response - * - * @deprecated use quoteResponses instead - */ - quoteResponse: QuoteResponse & QuoteMetadata; -}; - -export type QuoteResponseParam< - QuoteResponseType = QuoteResponse & QuoteMetadata, -> = { - /** - * An array of quote responses - */ - quoteResponses: [QuoteResponseType, ...QuoteResponseType[]]; -}; - -export type SubmitTxParams< - QRPType extends - | LegacyQuoteResponseParam - | QuoteResponseParam = QuoteResponseParam, -> = { - /** - * The address of the account to submit the transaction for - */ - accountAddress: string; - /** - * Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension - */ - isStxEnabled?: boolean; - /** - * The context for the QuotesReceived event - */ - quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived]; - /** - * The location/entry point from which the user initiated the swap or bridge. - * Used to attribute swaps to specific flows (e.g. Trending Explore). - */ - location?: MetaMetricsSwapsEventSource; - /** - * Legacy A/B test metrics context (`ab_tests`) kept for backward compatibility. - * Keys are test names, values are variant names (e.g. { token_details_layout: 'treatment' }). - */ - abTests?: Record; - /** - * New A/B test metrics context (`active_ab_tests`) that replaces `ab_tests`. - * Kept separate so migration can run both payloads in parallel. - * This field is an array of test objects. - */ - activeAbTests?: { key: string; value: string }[]; - /** - * The security classification of the destination token, supplied by the client - * (e.g. from token security/scanning data). Pass `null` when no security data is available. - */ - tokenSecurityTypeDestination?: string | null; - /** - * Contains transaction data for the quotes, - * provided by the obtainGaslessBatch API - */ - batchSimulationResponse?: BatchSimulationResponse; -} & QRPType; - -/** - * The legacy parameters for the transaction submission - * - * @deprecated Use {@link SubmitTxParams} instead - */ -export type SubmitTxLegacyParams = Parameters< - ( - accountAddress: SubmitTxParams['accountAddress'], - quoteResponse: QuoteResponse & QuoteMetadata, - isStxEnabled: SubmitTxParams['isStxEnabled'], - quotesReceivedContext?: SubmitTxParams['quotesReceivedContext'], - location?: SubmitTxParams['location'], - abTests?: SubmitTxParams['abTests'], - activeAbTests?: SubmitTxParams['activeAbTests'], - tokenSecurityTypeDestination?: SubmitTxParams['tokenSecurityTypeDestination'], - batchSimulationResponse?: SubmitTxParams['batchSimulationResponse'], - ) => void ->; - /** * These fields are specific to Solana transactions and can likely be infered from TransactionMeta * From e7c631354d3ec105c8bfbfd92ed33e8d99b5d220 Mon Sep 17 00:00:00 2001 From: micaelae Date: Wed, 13 May 2026 17:23:43 -0700 Subject: [PATCH 8/9] feat: fetch batch sell trades --- .../bridge-controller.sse.batch.test.ts.snap | 6 +- .../bridge-controller.sse.test.ts.snap | 4 + .../bridge-controller.test.ts.snap | 8 + .../bridge-controller-method-action-types.ts | 8 +- .../src/bridge-controller.sse.batch.test.ts | 922 +++++++++++++----- .../src/bridge-controller.test.ts | 4 + .../src/bridge-controller.ts | 83 ++ .../bridge-controller/src/constants/bridge.ts | 2 + packages/bridge-controller/src/index.ts | 6 +- .../bridge-controller/src/selectors.test.ts | 256 ++++- packages/bridge-controller/src/selectors.ts | 56 +- packages/bridge-controller/src/types.ts | 27 +- .../bridge-controller/src/utils/bridge.ts | 2 +- .../bridge-controller/src/utils/fetch.test.ts | 217 ++++- packages/bridge-controller/src/utils/fetch.ts | 50 + .../src/utils/metrics/constants.ts | 1 + packages/bridge-controller/src/utils/quote.ts | 20 + .../bridge-controller/src/utils/validators.ts | 60 +- 18 files changed, 1432 insertions(+), 300 deletions(-) diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap index c7f12b87dc..e116784742 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`BridgeController BatchSell (multiple quote requests) SSE should trigger quote polling if request is valid 1`] = ` +exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes should trigger quote polling if request is valid 1`] = ` { "assetExchangeRates": { "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { @@ -8,6 +8,8 @@ exports[`BridgeController BatchSell (multiple quote requests) SSE should trigger "usdExchangeRate": "100", }, }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ @@ -58,7 +60,7 @@ exports[`BridgeController BatchSell (multiple quote requests) SSE should trigger } `; -exports[`BridgeController BatchSell (multiple quote requests) SSE should trigger quote polling if request is valid 2`] = ` +exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes should trigger quote polling if request is valid 2`] = ` [ [ "Unified SwapBridge Input Changed", diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap index 5dbe5e22f4..76de1fc32f 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap @@ -179,6 +179,8 @@ exports[`BridgeController SSE should rethrow error from server 1`] = ` "usdExchangeRate": "100", }, }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ @@ -303,6 +305,8 @@ exports[`BridgeController SSE should trigger quote polling if request is valid 1 "usdExchangeRate": "100", }, }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap index ac91fb4f2c..e435d9ba2e 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap @@ -8,6 +8,8 @@ exports[`BridgeController should handle errors from fetchBridgeQuotes 1`] = ` "usdExchangeRate": "100", }, }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ @@ -39,6 +41,8 @@ exports[`BridgeController should handle errors from fetchBridgeQuotes 2`] = ` "usdExchangeRate": "100", }, }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ @@ -829,6 +833,8 @@ exports[`BridgeController updateBridgeQuoteRequestParams should reset minimumBal exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote polling if request is valid 1`] = ` { "assetExchangeRates": {}, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ @@ -864,6 +870,8 @@ exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote po "usdExchangeRate": "100", }, }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ diff --git a/packages/bridge-controller/src/bridge-controller-method-action-types.ts b/packages/bridge-controller/src/bridge-controller-method-action-types.ts index 0cc3d746a0..553707f0bf 100644 --- a/packages/bridge-controller/src/bridge-controller-method-action-types.ts +++ b/packages/bridge-controller/src/bridge-controller-method-action-types.ts @@ -40,6 +40,11 @@ export type BridgeControllerTrackUnifiedSwapBridgeEventAction = { handler: BridgeController['trackUnifiedSwapBridgeEvent']; }; +export type BridgeControllerFetchBatchSellTradesAction = { + type: `BridgeController:fetchBatchSellTrades`; + handler: BridgeController['fetchBatchSellTrades']; +}; + /** * Union of all BridgeController action types. */ @@ -50,4 +55,5 @@ export type BridgeControllerMethodActions = | BridgeControllerSetLocationAction | BridgeControllerResetStateAction | BridgeControllerSetChainIntervalLengthAction - | BridgeControllerTrackUnifiedSwapBridgeEventAction; + | BridgeControllerTrackUnifiedSwapBridgeEventAction + | BridgeControllerFetchBatchSellTradesAction; diff --git a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts index 1fccfffd7f..e6bbb46b72 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts @@ -203,250 +203,710 @@ async function withController( } describe('BridgeController BatchSell (multiple quote requests) SSE', function () { - beforeEach(() => { - jest.clearAllMocks(); - jest.clearAllTimers(); - jest.resetAllMocks(); - }); + describe('fetch quotes', function () { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.resetAllMocks(); + }); - it('should trigger quote polling if request is valid', async function () { - await withController( - async ({ - controller: bridgeController, - rootMessenger, - stopAllPollingSpy, - startPollingSpy, - hasSufficientBalanceSpy, - fetchBridgeQuotesSpy, - fetchAssetPricesSpy, - consoleLogSpy, - }) => { - mockFetchFn.mockImplementationOnce(async () => { - return mockSseBatchSellEventSource([ - mockBridgeQuotesNativeErc20 as QuoteResponse[], - mockBridgeQuotesErc20Erc20 as QuoteResponse[], - ]); - }); - hasSufficientBalanceSpy.mockResolvedValue(true); - - const selectIsAssetExchangeRateInStateSpy = jest.spyOn( - selectors, - 'selectIsAssetExchangeRateInState', - ); - - const quoteRequest0 = { - ...quoteRequest, - srcTokenAddress: - mockBridgeQuotesNativeErc20[0].quote.srcAsset.address, - destTokenAddress: - mockBridgeQuotesNativeErc20[0].quote.destAsset.address, - srcChainId: - mockBridgeQuotesNativeErc20[0].quote.srcAsset.chainId.toString(), - destChainId: - mockBridgeQuotesNativeErc20[0].quote.destAsset.chainId.toString(), - srcTokenAmount: '100000000000000000', - }; - const quoteRequest1 = { - ...quoteRequest, - srcTokenAddress: mockBridgeQuotesErc20Erc20[0].quote.srcAsset.address, - destTokenAddress: - mockBridgeQuotesErc20Erc20[0].quote.destAsset.address, - srcChainId: - mockBridgeQuotesErc20Erc20[0].quote.srcAsset.chainId.toString(), - destChainId: - mockBridgeQuotesErc20Erc20[0].quote.destAsset.chainId.toString(), - srcTokenAmount: '1000000000000000000', - }; - const quoteRequest2 = { - ...quoteRequest, - srcTokenAddress: - mockBridgeQuotesNativeErc20[0].quote.srcAsset.address, - destTokenAddress: - mockBridgeQuotesNativeErc20[0].quote.destAsset.address, - srcChainId: - mockBridgeQuotesNativeErc20[0].quote.srcAsset.chainId.toString(), - destChainId: - mockBridgeQuotesNativeErc20[0].quote.destAsset.chainId.toString(), - srcTokenAmount: '1000000000000000000', - }; - - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest2, - metricsContext, - 4, - 1, - ); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest2, - metricsContext, - 1, - 2, - ); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest2, - metricsContext, - 4, - 1, - ); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest0, - metricsContext, - 0, - 1, - ); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest1, - metricsContext, - 1, - 2, - ); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest1, - metricsContext, - 1, - 3, - ); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - quoteRequest2, - metricsContext, - 2, - 3, - ); - - // Before polling starts - expect(stopAllPollingSpy).toHaveBeenCalledTimes(5); - expect(startPollingSpy).toHaveBeenCalledTimes(4); - expect( - startPollingSpy.mock.calls - .map((call) => call[0].quoteRequests) - .flat() - .find((call) => !call), - ).toBeUndefined(); - expect(bridgeController.state.quoteRequest).toStrictEqual([ - { ...quoteRequest0, insufficientBal: false }, - { ...quoteRequest1, insufficientBal: false }, - { ...quoteRequest2, insufficientBal: false }, - ]); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); - const expectedState = { - ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quoteRequest: [ + it('should trigger quote polling if request is valid', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseBatchSellEventSource([ + mockBridgeQuotesNativeErc20 as QuoteResponse[], + mockBridgeQuotesErc20Erc20 as QuoteResponse[], + ]); + }); + hasSufficientBalanceSpy.mockResolvedValue(true); + + const selectIsAssetExchangeRateInStateSpy = jest.spyOn( + selectors, + 'selectIsAssetExchangeRateInState', + ); + + const quoteRequest0 = { + ...quoteRequest, + srcTokenAddress: + mockBridgeQuotesNativeErc20[0].quote.srcAsset.address, + destTokenAddress: + mockBridgeQuotesNativeErc20[0].quote.destAsset.address, + srcChainId: + mockBridgeQuotesNativeErc20[0].quote.srcAsset.chainId.toString(), + destChainId: + mockBridgeQuotesNativeErc20[0].quote.destAsset.chainId.toString(), + srcTokenAmount: '100000000000000000', + }; + const quoteRequest1 = { + ...quoteRequest, + srcTokenAddress: + mockBridgeQuotesErc20Erc20[0].quote.srcAsset.address, + destTokenAddress: + mockBridgeQuotesErc20Erc20[0].quote.destAsset.address, + srcChainId: + mockBridgeQuotesErc20Erc20[0].quote.srcAsset.chainId.toString(), + destChainId: + mockBridgeQuotesErc20Erc20[0].quote.destAsset.chainId.toString(), + srcTokenAmount: '1000000000000000000', + }; + const quoteRequest2 = { + ...quoteRequest, + srcTokenAddress: + mockBridgeQuotesNativeErc20[0].quote.srcAsset.address, + destTokenAddress: + mockBridgeQuotesNativeErc20[0].quote.destAsset.address, + srcChainId: + mockBridgeQuotesNativeErc20[0].quote.srcAsset.chainId.toString(), + destChainId: + mockBridgeQuotesNativeErc20[0].quote.destAsset.chainId.toString(), + srcTokenAmount: '1000000000000000000', + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest2, + metricsContext, + 4, + 1, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest2, + metricsContext, + 1, + 2, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest2, + metricsContext, + 4, + 1, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest0, + metricsContext, + 0, + 1, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest1, + metricsContext, + 1, + 2, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest1, + metricsContext, + 1, + 3, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest2, + metricsContext, + 2, + 3, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(5); + expect(startPollingSpy).toHaveBeenCalledTimes(4); + expect( + startPollingSpy.mock.calls + .map((call) => call[0].quoteRequests) + .flat() + .find((call) => !call), + ).toBeUndefined(); + expect(bridgeController.state.quoteRequest).toStrictEqual([ { ...quoteRequest0, insufficientBal: false }, { ...quoteRequest1, insufficientBal: false }, { ...quoteRequest2, insufficientBal: false }, - ], - quotesLoadingStatus: RequestStatus.LOADING, - }; - expect(bridgeController.state).toStrictEqual(expectedState); - - // Loading state - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - expect(bridgeController.state.quotesLoadingStatus).toBe( - RequestStatus.LOADING, - ); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(4); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( - mockFetchFn, - [ + ]); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: [ + { ...quoteRequest0, insufficientBal: false }, + { ...quoteRequest1, insufficientBal: false }, + { ...quoteRequest2, insufficientBal: false }, + ], + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.LOADING, + ); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(4); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + [ + { + ...quoteRequest0, + insufficientBal: false, + resetApproval: false, + }, + { + ...quoteRequest1, + insufficientBal: false, + resetApproval: false, + }, + { + ...quoteRequest2, + insufficientBal: false, + resetApproval: false, + }, + ], + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + BRIDGE_PROD_API_BASE_URL, { - ...quoteRequest0, - insufficientBal: false, - resetApproval: false, + onQuoteValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), + onComplete: expect.any(Function), + onClose: expect.any(Function), }, - { - ...quoteRequest1, - insufficientBal: false, - resetApproval: false, + '13.8.0', + ); + const { quotesLastFetched: t1, ...stateWithoutTimestamp } = + bridgeController.state; + // eslint-disable-next-line jest/no-restricted-matchers + expect(stateWithoutTimestamp).toMatchSnapshot(); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: [ + { + ...quoteRequest0, + insufficientBal: false, + resetApproval: false, + }, + { + ...quoteRequest1, + insufficientBal: false, + resetApproval: false, + }, + { + ...quoteRequest2, + insufficientBal: false, + resetApproval: false, + }, + ], + quotes: mockBridgeQuotesNativeErc20 + .map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + quoteRequestIndex: 0, + })) + .concat( + mockBridgeQuotesErc20Erc20.map( + (quote) => + ({ + ...quote, + l1GasFeesInHexWei: '0x2', + resetApproval: undefined, + quoteRequestIndex: 1, + } as never), + ), + ), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + assetExchangeRates, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(4); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalledTimes(12); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + }, + ); + }); + }); + + describe('fetch trades/fees', function () { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.resetAllMocks(); + }); + + it('should fetch batch gasless trades and fees', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + jest.useFakeTimers(); + const abortControllerSpy = jest.spyOn( + AbortController.prototype, + 'abort', + ); + const fetchBatchSellTradesSpy = jest.spyOn( + fetchUtils, + 'fetchBatchSellTrades', + ); + const mockBatchSellTrades = { + transactions: [], + fee: { + amount: '100', + asset: { + symbol: 'USDC', + chainId: 10, + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + name: 'USD Coin', + decimals: 6, + assetId: + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + } as const, }, - { - ...quoteRequest2, - insufficientBal: false, - resetApproval: false, + }; + + // Before initial fetch + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(0); + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(0); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toBeNull(); + expect( + bridgeController.state.batchSellTradesLoadingStatus, + ).toBeNull(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + expect(bridgeController.state).toStrictEqual( + DEFAULT_BRIDGE_CONTROLLER_STATE, + ); + + fetchBatchSellTradesSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + jest.useRealTimers(); + setTimeout(() => { + jest.useFakeTimers(); + resolve(mockBatchSellTrades); + }, 2000); + }), + ); + + // Initial fetch + await rootMessenger.call('BridgeController:fetchBatchSellTrades', []); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(0); + expect(fetchBatchSellTradesSpy.mock.calls[0][0].quotes).toStrictEqual( + [], + ); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toStrictEqual( + mockBatchSellTrades, + ); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: mockBatchSellTrades, + }); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(`[]`); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); + jest.useRealTimers(); + }, + ); + }); + + it('should abort previous fetch if new fetch is called', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + jest.useFakeTimers(); + const abortControllerSpy = jest.spyOn( + AbortController.prototype, + 'abort', + ); + const fetchBatchSellTradesSpy = jest.spyOn( + fetchUtils, + 'fetchBatchSellTrades', + ); + const mockBatchSellTrades = { + transactions: [], + fee: { + amount: '100', + asset: { + symbol: 'USDC', + chainId: 10, + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + name: 'USD Coin', + decimals: 6, + assetId: + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + } as const, + }, + }; + const mockBatchSellTrades2 = { + transactions: [], + fee: { + amount: '500', + asset: { + ...mockBatchSellTrades.fee.asset, + }, }, - ], - expect.any(AbortSignal), - BridgeClientId.EXTENSION, - 'AUTH_TOKEN', - BRIDGE_PROD_API_BASE_URL, - { - onQuoteValidationFailure: expect.any(Function), - onValidQuoteReceived: expect.any(Function), - onTokenWarning: expect.any(Function), - onComplete: expect.any(Function), - onClose: expect.any(Function), - }, - '13.8.0', - ); - const { quotesLastFetched: t1, ...stateWithoutTimestamp } = - bridgeController.state; - // eslint-disable-next-line jest/no-restricted-matchers - expect(stateWithoutTimestamp).toMatchSnapshot(); - expect(t1).toBeCloseTo(Date.now() - 1000); - - // After first fetch - jest.advanceTimersByTime(5000); - await flushPromises(); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); - expect(bridgeController.state).toStrictEqual({ - ...expectedState, - quotesInitialLoadTime: 6000, - quoteRequest: [ + }; + + // Before initial fetch + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(0); + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(0); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toBeNull(); + expect( + bridgeController.state.batchSellTradesLoadingStatus, + ).toBeNull(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + expect(bridgeController.state).toStrictEqual( + DEFAULT_BRIDGE_CONTROLLER_STATE, + ); + + fetchBatchSellTradesSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + jest.useRealTimers(); + setTimeout(() => { + jest.useFakeTimers(); + resolve(mockBatchSellTrades); + }, 2000); + }), + ); + + fetchBatchSellTradesSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + resolve(mockBatchSellTrades2); + }), + ); + + // Call twice in a row + await rootMessenger.call('BridgeController:fetchBatchSellTrades', []); + await rootMessenger.call( + 'BridgeController:fetchBatchSellTrades', + mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], + ); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(1); + expect(fetchBatchSellTradesSpy.mock.calls[0][0].quotes).toStrictEqual( + [], + ); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toStrictEqual( + mockBatchSellTrades2, + ); + expect( + bridgeController.state.batchSellTradesLoadingStatus, + ).toStrictEqual(RequestStatus.FETCHED); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: mockBatchSellTrades2, + }); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(2); + expect(fetchBatchSellTradesSpy.mock.calls[0]).toStrictEqual([ { - ...quoteRequest0, - insufficientBal: false, - resetApproval: false, + quotes: [], }, + expect.any(AbortSignal), + 'extension', + 'AUTH_TOKEN', + expect.any(Function), + 'https://bridge.api.cx.metamask.io', + '13.8.0', + ]); + expect(fetchBatchSellTradesSpy.mock.calls[1]).toStrictEqual([ { - ...quoteRequest1, - insufficientBal: false, - resetApproval: false, + quotes: mockBridgeQuotesErc20Erc20, + }, + expect.any(AbortSignal), + 'extension', + 'AUTH_TOKEN', + expect.any(Function), + 'https://bridge.api.cx.metamask.io', + '13.8.0', + ]); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(`[]`); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); + jest.useRealTimers(); + }, + ); + }); + + it('should abort previous fetch if resetState is called', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + jest.useFakeTimers(); + const abortControllerSpy = jest.spyOn( + AbortController.prototype, + 'abort', + ); + const fetchBatchSellTradesSpy = jest.spyOn( + fetchUtils, + 'fetchBatchSellTrades', + ); + const mockBatchSellTrades = { + transactions: [], + fee: { + amount: '100', + asset: { + symbol: 'USDC', + chainId: 10, + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + name: 'USD Coin', + decimals: 6, + assetId: + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + } as const, }, + }; + + // Before initial fetch + expect(bridgeController.state).toStrictEqual( + DEFAULT_BRIDGE_CONTROLLER_STATE, + ); + + fetchBatchSellTradesSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + jest.useRealTimers(); + setTimeout(() => { + jest.useFakeTimers(); + resolve(mockBatchSellTrades); + }, 2000); + }), + ); + + // Reset after starting fetch + await rootMessenger.call('BridgeController:fetchBatchSellTrades', []); + rootMessenger.call('BridgeController:resetState'); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(abortControllerSpy).toHaveBeenCalledTimes(2); + expect(fetchBatchSellTradesSpy.mock.calls[0][0].quotes).toStrictEqual( + [], + ); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toBeNull(); + expect( + bridgeController.state.batchSellTradesLoadingStatus, + ).toBeNull(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(bridgeController.state).toStrictEqual( + DEFAULT_BRIDGE_CONTROLLER_STATE, + ); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(1); + expect(fetchBatchSellTradesSpy.mock.calls[0]).toStrictEqual([ { - ...quoteRequest2, - insufficientBal: false, - resetApproval: false, + quotes: [], }, - ], - quotes: mockBridgeQuotesNativeErc20 - .map((quote) => ({ - ...quote, - l1GasFeesInHexWei: '0x1', - resetApproval: undefined, - quoteRequestIndex: 0, - })) - .concat( - mockBridgeQuotesErc20Erc20.map( - (quote) => - ({ - ...quote, - l1GasFeesInHexWei: '0x2', - resetApproval: undefined, - quoteRequestIndex: 1, - }) as never, - ), - ), - quotesRefreshCount: 1, - quotesLoadingStatus: 1, - quotesLastFetched: t1, - assetExchangeRates, - }); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(consoleLogSpy).toHaveBeenCalledTimes(0); - expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(4); - expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); - // eslint-disable-next-line jest/no-restricted-matchers - expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); - expect(selectIsAssetExchangeRateInStateSpy).toHaveBeenCalledTimes(12); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); - }, - ); + expect.any(AbortSignal), + 'extension', + 'AUTH_TOKEN', + expect.any(Function), + 'https://bridge.api.cx.metamask.io', + '13.8.0', + ]); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(`[]`); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); + jest.useRealTimers(); + }, + ); + }); + + it('should reset batch trade states if fetch throws an error', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + jest.useFakeTimers(); + const abortControllerSpy = jest.spyOn( + AbortController.prototype, + 'abort', + ); + const fetchBatchSellTradesSpy = jest.spyOn( + fetchUtils, + 'fetchBatchSellTrades', + ); + const mockBatchSellTrades = { + transactions: [], + fee: { + amount: '100', + asset: { + symbol: 'USDC', + chainId: 10, + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + name: 'USD Coin', + decimals: 6, + assetId: + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + } as const, + }, + }; + + expect(bridgeController.state).toStrictEqual( + DEFAULT_BRIDGE_CONTROLLER_STATE, + ); + + fetchBatchSellTradesSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + jest.useRealTimers(); + setTimeout(() => { + jest.useFakeTimers(); + resolve(mockBatchSellTrades); + }, 1000); + }), + ); + fetchBatchSellTradesSpy.mockRejectedValueOnce( + new Error('Network error'), + ); + + // 1st fetch + await rootMessenger.call('BridgeController:fetchBatchSellTrades', []); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(0); + expect(fetchBatchSellTradesSpy.mock.calls[0][0].quotes).toStrictEqual( + [], + ); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toStrictEqual( + mockBatchSellTrades, + ); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + expect(bridgeController.state.batchSellTradesLoadingStatus).toBe( + RequestStatus.FETCHED, + ); + + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + batchSellTrades: mockBatchSellTrades, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + }); + + // 2nd fetch + await rootMessenger.call( + 'BridgeController:fetchBatchSellTrades', + mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], + ); + + await jest.advanceTimersByTimeAsync(2000); + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(1); + expect(fetchBatchSellTradesSpy.mock.calls[1][0].quotes).toStrictEqual( + mockBridgeQuotesErc20Erc20, + ); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toBeNull(); + expect(bridgeController.state.batchSellTradesLoadingStatus).toBe( + RequestStatus.ERROR, + ); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + batchSellTradesLoadingStatus: RequestStatus.ERROR, + }); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(2); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Failed to fetch batch sell trades", + [Error: Network error], + ], + ] + `); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); + jest.useRealTimers(); + }, + ); + }); }); }); diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index bd28435522..07d58a7b44 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -4070,6 +4070,8 @@ describe('BridgeController', function () { ).toMatchInlineSnapshot(` { "assetExchangeRates": {}, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ @@ -4113,6 +4115,8 @@ describe('BridgeController', function () { ).toMatchInlineSnapshot(` { "assetExchangeRates": {}, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index ed5138dcb3..f230d862e6 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -58,6 +58,7 @@ import { fetchAssetPrices, fetchBridgeQuotes, fetchBridgeQuoteStream, + fetchBatchSellTrades, } from './utils/fetch'; import { AbortReason, @@ -162,6 +163,18 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + batchSellTrades: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + batchSellTradesLoadingStatus: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, }; /** @@ -197,6 +210,7 @@ type BridgePollingInput = { const MESSENGER_EXPOSED_METHODS = [ 'updateBridgeQuoteRequestParams', 'fetchQuotes', + 'fetchBatchSellTrades', 'stopPollingForQuotes', 'setLocation', 'resetState', @@ -211,6 +225,8 @@ export class BridgeController extends StaticIntervalPollingController { #abortController: AbortController | undefined; + #batchSellTradesAbortController: AbortController | undefined; + #quotesFirstFetched: number | undefined; /** @@ -428,6 +444,69 @@ export class BridgeController extends StaticIntervalPollingController => { + this.#batchSellTradesAbortController?.abort( + AbortReason.GaslessTxBatchFetched, + ); + this.#batchSellTradesAbortController = new AbortController(); + + this.update((state) => { + state.batchSellTradesLoadingStatus = RequestStatus.LOADING; + }); + + try { + const batchSellTradesResponse = await fetchBatchSellTrades( + { quotes }, + this.#batchSellTradesAbortController.signal, + this.#clientId, + await this.#getJwt(), + this.#fetchFn, + this.#config.customBridgeApiBaseUrl ?? BRIDGE_PROD_API_BASE_URL, + this.#clientVersion, + ); + + this.update((state) => { + state.batchSellTrades = batchSellTradesResponse; + state.batchSellTradesLoadingStatus = RequestStatus.FETCHED; + }); + + // TODO if fee.asset.assetId is not in exchange rates, fetch the exchange rate and update the state + } catch (error) { + // Reset the batch sell trades if the fetch fails to avoid showing stale data + this.update((state) => { + state.batchSellTrades = DEFAULT_BRIDGE_CONTROLLER_STATE.batchSellTrades; + }); + // Ignore abort errors + if ( + (error as Error).toString().includes('AbortError') || + (error as Error).toString().includes('FetchRequestCanceledException') || + [ + AbortReason.ResetState, + AbortReason.NewQuoteRequest, + AbortReason.QuoteRequestUpdated, + AbortReason.TransactionSubmitted, + AbortReason.GaslessTxBatchFetched, + ].includes(error as AbortReason) + ) { + // Exit the function early to prevent other state updates + return; + } + + // Update loading status + this.update((state) => { + state.batchSellTradesLoadingStatus = RequestStatus.ERROR; + }); + console.log(`Failed to fetch batch sell trades`, error); + } + }; + readonly #trackQuoteValidationFailures = (validationFailures: string[]) => { if (validationFailures.length === 0) { return; @@ -628,6 +707,7 @@ export class BridgeController extends StaticIntervalPollingController = { diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 66008683f3..5ef53d9df0 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -40,7 +40,7 @@ export type { BridgeAsset, GenericQuoteRequest, Protocol, - BatchSimulationResponse, + BatchSellTradesResponse, GaslessProperties, SimulatedGasFeeLimits, TokenAmountValues, @@ -60,6 +60,7 @@ export type { BridgeControllerEvents, BridgeControllerMessenger, FeatureFlagsPlatformConfig, + TxFeeGasLimits, } from './types'; export type { @@ -70,6 +71,7 @@ export type { BridgeControllerResetStateAction, BridgeControllerSetChainIntervalLengthAction, BridgeControllerTrackUnifiedSwapBridgeEventAction, + BridgeControllerFetchBatchSellTradesAction, } from './bridge-controller-method-action-types'; export { AbortReason } from './utils/metrics/constants'; @@ -97,6 +99,7 @@ export { TokenFeatureType, validateQuoteStreamComplete, QuoteStreamCompleteReason, + BatchSimulationTransactionType, } from './utils/validators'; export { @@ -178,6 +181,7 @@ export { export { selectBridgeQuotes, selectBatchSellQuotes, + selectBatchSellTrades, selectDefaultSlippagePercentage, type BridgeAppState, selectExchangeRateByAssetId, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index bd5162f310..11481227fb 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -18,6 +18,7 @@ import { selectDefaultSlippagePercentage, selectTokenWarnings, selectBatchSellQuotes, + selectBatchSellTrades, } from './selectors'; import type { BridgeAsset, QuoteResponse } from './types'; import { SortOrder, RequestStatus, ChainId } from './types'; @@ -26,6 +27,7 @@ import { formatAddressToAssetId, formatChainIdToHex, } from './utils/caip-formatters'; +import { BatchSimulationTransactionType } from './utils/validators'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const MOCK_MUSD_ADDRESS = '0x12345A7890123456789012345678901234567890'; @@ -1486,13 +1488,8 @@ describe('Bridge Selectors', () => { { ...mockClientParams, requestCount: 2 }, ); - const { - totalReceived, - minimumReceived, - totalNetworkFee, - recommendedQuotes, - ...rest - } = result; + const { totalReceived, minimumReceived, recommendedQuotes, ...rest } = + result; expect(totalReceived).toMatchInlineSnapshot(` { @@ -1508,13 +1505,6 @@ describe('Bridge Selectors', () => { "valueInCurrency": "7520", } `); - expect(totalNetworkFee).toMatchInlineSnapshot(` - { - "amount": "0.0020959506", - "usd": "3.77271108", - "valueInCurrency": "3.77271108", - } - `); expect(rest).toMatchInlineSnapshot(` { "isLoading": false, @@ -1552,13 +1542,8 @@ describe('Bridge Selectors', () => { { ...mockClientParams, requestCount: 2 }, ); - const { - totalReceived, - minimumReceived, - totalNetworkFee, - recommendedQuotes, - ...rest - } = result; + const { totalReceived, minimumReceived, recommendedQuotes, ...rest } = + result; expect(totalReceived).toMatchInlineSnapshot(` { @@ -1574,13 +1559,6 @@ describe('Bridge Selectors', () => { "valueInCurrency": "0", } `); - expect(totalNetworkFee).toMatchInlineSnapshot(` - { - "amount": "0", - "usd": "0", - "valueInCurrency": "0", - } - `); expect(rest).toMatchInlineSnapshot(` { "isLoading": false, @@ -1594,6 +1572,228 @@ describe('Bridge Selectors', () => { }); }); + describe('selectBatchSellTrades', () => { + const getMockState = (chainId: string): BridgeAppState => + ({ + quotes: [ + ...mockQuotesErc20Erc20.map((quote) => ({ + ...quote, + quoteRequestIndex: 1, + })), + ...mockQuotesNativeErc20.map((quote) => ({ + ...quote, + quoteRequestIndex: 0, + })), + ], + quoteRequest: [ + { + srcChainId: '10', + destChainId: '137', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + insufficientBal: false, + }, + { + srcChainId: '10', + destChainId: '137', + srcTokenAddress: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + destTokenAddress: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + insufficientBal: false, + }, + ], + quotesLastFetched: Date.now(), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteFetchError: null, + quotesRefreshCount: 0, + quotesInitialLoadTime: Date.now(), + remoteFeatureFlags: { + bridgeConfig: { + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + chainRanking: [], + chains: {}, + support: true, + }, + }, + assetExchangeRates: {}, + currencyRates: { + ETH: { + conversionRate: 1800, + usdConversionRate: 1800, + }, + }, + marketData: {}, + conversionRates: {}, + participateInMetaMetrics: true, + gasFeeEstimatesByChainId: { + [formatChainIdToHex(chainId)]: { + gasFeeEstimates: { + estimatedBaseFee: '0', + medium: { + suggestedMaxPriorityFeePerGas: '.1', + suggestedMaxFeePerGas: '.1', + }, + high: { + suggestedMaxPriorityFeePerGas: '.1', + suggestedMaxFeePerGas: '.2', + }, + }, + }, + }, + }) as unknown as BridgeAppState; + + const mockState = getMockState('10'); + + const mockBatchSellTrades = { + transactions: [ + { + chainId: 137, + to: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + from: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + value: '0x0', + data: '0x', + gasLimit: 21000, + effectiveGas: 21000, + maxFeePerGas: '0x5d21dba00', + maxPriorityFeePerGas: '0x5d21dba00', + type: BatchSimulationTransactionType.TRANSFER, + } as const, + ], + fee: { + amount: '10000', + asset: { + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359' as const, + symbol: 'USDC', + chainId: 137, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + name: 'USD Coin', + decimals: 6, + }, + }, + }; + + it('should return total network fee', () => { + const result = selectBatchSellTrades({ + ...mockState, + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { + exchangeRate: '200', + usdExchangeRate: '5', + }, + }, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: mockBatchSellTrades, + }); + + expect(result.batchSellTrades).toStrictEqual(mockBatchSellTrades); + expect(result.totalNetworkFee).toMatchInlineSnapshot(` + { + "amount": "0.01", + "asset": { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "chainId": 137, + "decimals": 6, + "name": "USD Coin", + "symbol": "USDC", + }, + "usd": "0.05", + "valueInCurrency": "2", + } + `); + expect(result.isLoading).toBe(false); + }); + + it('should return total network fee (exchange rates are not available)', () => { + const result = selectBatchSellTrades({ + ...mockState, + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff84': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3354': { + exchangeRate: '200', + usdExchangeRate: '5', + }, + }, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: mockBatchSellTrades, + }); + + expect(result.batchSellTrades).toStrictEqual(mockBatchSellTrades); + expect(result.totalNetworkFee).toMatchInlineSnapshot(` + { + "amount": "0.01", + "asset": { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "chainId": 137, + "decimals": 6, + "name": "USD Coin", + "symbol": "USDC", + }, + "usd": null, + "valueInCurrency": null, + } + `); + expect(result.isLoading).toBe(false); + }); + + it('should return empty data when batch sell trades are not defined', () => { + const result = selectBatchSellTrades({ + ...mockState, + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { + exchangeRate: '200', + usdExchangeRate: '5', + }, + }, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: null, + }); + + expect(result.batchSellTrades).toBeNull(); + expect(result.totalNetworkFee).toMatchInlineSnapshot(`undefined`); + expect(result.isLoading).toBe(false); + }); + + it.each([ + { status: RequestStatus.LOADING, expectedResult: true }, + { status: RequestStatus.FETCHED, expectedResult: false }, + ])( + 'should return loading state when status is $status', + ({ status, expectedResult }) => { + const { isLoading } = selectBatchSellTrades({ + ...mockState, + batchSellTradesLoadingStatus: status, + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }); + + expect(isLoading).toBe(expectedResult); + }, + ); + }); + describe('selectBridgeFeatureFlags', () => { const mockValidBridgeConfig = { minimumVersion: '0.0.0', diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 15ecb79b20..900538fe05 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -51,6 +51,7 @@ import { calcToAmount, calcTotalEstimatedNetworkFee, calcTotalMaxNetworkFee, + calcBatchFees, } from './utils/quote'; import { getDefaultSlippagePercentage } from './utils/slippage'; @@ -527,8 +528,8 @@ export const selectIsQuoteExpired = createBridgeSelector( (isQuoteGoingToRefresh, quotesLastFetched, refreshRate, currentTimeInMs) => Boolean( !isQuoteGoingToRefresh && - quotesLastFetched && - currentTimeInMs - quotesLastFetched > refreshRate, + quotesLastFetched && + currentTimeInMs - quotesLastFetched > refreshRate, ), ); @@ -615,7 +616,7 @@ const selectMetadataSum = createBridgeSelector( * * @example * ```ts - * const quotes = useSelector(state => selectBridgeQuotesBatch( + * const quotes = useSelector(state => selectBatchSellQuotes( * { ...state.metamask }, * { * sortOrder: state.bridge.sortOrder, @@ -630,9 +631,6 @@ export const selectBatchSellQuotes = createStructuredBridgeSelector({ selectMetadataSum(state, { ...opts, key: 'toTokenAmount' }), minimumReceived: (state, opts) => selectMetadataSum(state, { ...opts, key: 'minToTokenAmount' }), - // TODO call estimation API - totalNetworkFee: (state, opts) => - selectMetadataSum(state, { ...opts, key: 'totalNetworkFee' }), quotesLastFetchedMs: (state) => state.quotesLastFetched, isLoading: (state) => state.quotesLoadingStatus === RequestStatus.LOADING, quoteFetchError: (state) => state.quoteFetchError, @@ -641,6 +639,52 @@ export const selectBatchSellQuotes = createStructuredBridgeSelector({ isQuoteGoingToRefresh: selectIsQuoteGoingToRefresh, }); +const selectBatchSellFees = createBridgeSelector( + [ + (state) => state.batchSellTrades?.fee.amount, + (state) => state.batchSellTrades?.fee.asset, + (state) => + selectExchangeRateByAssetId( + state, + state.batchSellTrades?.fee.asset?.assetId, + ), + ], + (feeAmount, feeAsset, exchangeRate) => { + return feeAmount && feeAsset && exchangeRate + ? calcBatchFees(feeAmount, feeAsset, exchangeRate) + : undefined; + }, +); + +/** + * Selects the batch transactions and fees for a batch of quotes + * + * @param state - The state of the bridge controller and its dependency controllers + * @param sortOrder - The sort order of the quotes + * @param requestCount - The number of quote requests fetched in the batch + * @returns The quotes for multiple quote requests, including their recommendedQuotes, + * totalReceived, minimumReceived, totalNetworkFee, and other quote fetching metadata. + * + * @example + * ```ts + * const quotes = useSelector(state => selectBatchSellTrades(state.metamask)); + * ``` + */ +export const selectBatchSellTrades = createBridgeSelector( + [ + (state) => state.batchSellTradesLoadingStatus === RequestStatus.LOADING, + (state) => state.batchSellTrades, + selectBatchSellFees, + ], + (isLoading, batchSellTrades, batchFees) => { + return { + batchSellTrades, + totalNetworkFee: batchFees, + isLoading, + }; + }, +); + export const selectMinimumBalanceForRentExemptionInSOL = ( state: BridgeAppState, ) => diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 5b0d648850..de765f19a6 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -47,9 +47,10 @@ import type { QuoteStreamCompleteSchema, TronTradeDataSchema, TxDataSchema, - BatchSimulationResponseSchema, + BatchSellTradesResponseSchema, GaslessPropertiesSchema, SimulatedGasFeeLimitsSchema, + TxFeeGasLimitsSchema, } from './utils/validators'; export type FetchFunction = ( @@ -313,14 +314,20 @@ export type QuoteResponse< quoteRequestIndex?: number; }; +export type BatchSellTradesRequest = { + quotes: QuoteResponse[]; +}; + /** * This is the bridge-api response for the obtainGaslessBatch method */ -export type BatchSimulationResponse = Infer< - typeof BatchSimulationResponseSchema +export type BatchSellTradesResponse = Infer< + typeof BatchSellTradesResponseSchema >; export type SimulatedGasFeeLimits = Infer; +export type TxFeeGasLimits = Infer; + export type GaslessProperties = Infer; export enum ChainId { @@ -349,9 +356,9 @@ export type TokenFeature = Infer; export type QuoteStreamCompleteData = Infer; export enum RequestStatus { - LOADING, - FETCHED, - ERROR, + LOADING = 0, + FETCHED = 1, + ERROR = 2, } /** @@ -430,6 +437,14 @@ export type BridgeControllerState = { * Set to null at the start of each fetch and updated when the complete event is received. */ quoteStreamComplete: QuoteStreamCompleteData | null; + /** + * Contains gasless transaction data and fees for BatchSell quotes, provided by the obtainGaslessBatch API + */ + batchSellTrades: BatchSellTradesResponse | null; + /** + * The status of the batch sell trades fetch, including fee calculations and validations + */ + batchSellTradesLoadingStatus: RequestStatus | null; }; /** diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts index 204b164d51..980f88f3fc 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -126,7 +126,7 @@ export const getEthUsdtResetData = ( '0', ]); - return data; + return data as Hex; }; export const isEthUsdt = ( diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index a8581a0a33..7f62794c07 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -8,8 +8,10 @@ import { fetchBridgeQuotes, fetchBridgeTokens, fetchAssetPrices, + fetchBatchSellTrades, } from './fetch'; -import { FeatureId } from './validators'; +import { BatchSimulationTransactionType, FeatureId } from './validators'; +import { QuoteResponse } from '../types'; const mockFetchFn = jest.fn(); @@ -695,4 +697,217 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledTimes(3); }); }); + + describe('fetchBatchSellTrades', () => { + const mockConsoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(jest.fn()); + const mockBatchSellTrades = { + transactions: mockBridgeQuotesErc20Erc20.flatMap( + ({ trade, approval }) => [ + { + ...trade, + type: BatchSimulationTransactionType.TRADE, + maxFeePerGas: '0x123', + maxPriorityFeePerGas: '0x456', + }, + { + ...approval, + type: BatchSimulationTransactionType.APPROVAL, + maxFeePerGas: '0x123', + maxPriorityFeePerGas: '0x456', + }, + ], + ), + fee: { + amount: '100', + asset: { + symbol: 'USDC', + chainId: 10, + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + name: 'USD Coin', + decimals: 6, + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + } as const, + }, + }; + + // TODO error response + it('should fetch batch sell trades', async () => { + mockFetchFn.mockResolvedValue(mockBatchSellTrades); + const { signal } = new AbortController(); + + const result = await fetchBatchSellTrades( + { quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[] }, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/obtainGaslessBatch', + { + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + Authorization: 'Bearer AUTH_TOKEN', + 'Content-Type': 'application/json', + }, + signal, + method: 'POST', + body: JSON.stringify({ + quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], + }), + }, + ); + + expect(result).toStrictEqual(mockBatchSellTrades); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + mockConsoleWarn.mockRestore(); + }); + + it('should rethrow fetch error', async () => { + mockFetchFn.mockRejectedValue(new Error('Fetch error')); + const { signal } = new AbortController(); + + await expect( + fetchBatchSellTrades( + { quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[] }, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ), + ).rejects.toThrow('Fetch error'); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/obtainGaslessBatch', + { + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + Authorization: 'Bearer AUTH_TOKEN', + 'Content-Type': 'application/json', + }, + signal, + method: 'POST', + body: JSON.stringify({ + quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], + }), + }, + ); + + expect(mockConsoleWarn).not.toHaveBeenCalled(); + mockConsoleWarn.mockRestore(); + }); + + it('should fetch batch sell trades (malformed response)', async () => { + mockFetchFn.mockResolvedValueOnce({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map( + ({ maxFeePerGas, maxPriorityFeePerGas, ...rest }) => rest, + ), + }); + mockFetchFn.mockResolvedValueOnce({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map((trade) => ({ + ...trade, + maxFeePerGas: 1000, + maxPriorityFeePerGas: 1000, + })), + }); + mockFetchFn.mockResolvedValueOnce({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map((trade) => ({ + ...trade, + maxFeePerGas: '1000', + maxPriorityFeePerGas: '1000', + })), + }); + mockFetchFn.mockResolvedValueOnce({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map((trade) => ({ + ...trade, + maxFeePerGas: 0x123, + maxPriorityFeePerGas: 0x456, + })), + }); + + const { signal } = new AbortController(); + + await expect( + fetchBatchSellTrades( + { + quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], + }, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ), + ).rejects.toThrow('Invalid batch simulation response'); + + const result = await Promise.allSettled( + Array.from({ length: 3 }, () => + fetchBatchSellTrades( + { + quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], + }, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ), + ), + ); + + expect(mockFetchFn).toHaveBeenCalledTimes(4); + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/obtainGaslessBatch', + { + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + Authorization: 'Bearer AUTH_TOKEN', + 'Content-Type': 'application/json', + }, + signal, + method: 'POST', + body: JSON.stringify({ + quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], + }), + }, + ); + + expect( + result.map((error) => ({ ...error, reason: error.reason?.message })), + ).toMatchInlineSnapshot(` + [ + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a value of type \`HexString\`, but received: \`1000\`", + "status": "rejected", + }, + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a value of type \`HexString\`, but received: \`"1000"\`", + "status": "rejected", + }, + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a value of type \`HexString\`, but received: \`291\`", + "status": "rejected", + }, + ] + `); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + mockConsoleWarn.mockRestore(); + }); + }); }); diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index a5ab14e14d..6819ad1791 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -10,6 +10,8 @@ import type { BridgeAsset, TokenFeature, QuoteStreamCompleteData, + BatchSellTradesRequest, + BatchSellTradesResponse, } from '../types'; import { getEthUsdtResetData } from './bridge'; import { @@ -25,6 +27,7 @@ import { validateSwapsTokenObject, validateTokenFeature, validateQuoteStreamComplete, + validateBatchSellTradesResponse, } from './validators'; export const getClientHeaders = ({ @@ -489,3 +492,50 @@ export async function fetchBridgeQuoteStream( ...sharedFetchOptions, }); } + +/** + * Fetches quotes from the bridge-api's getQuote endpoint + * + * @param request - The quote request + * @param signal - The abort signal + * @param clientId - The client ID for metrics + * @param jwt - The JWT token for authentication + * @param fetchFn - The fetch function to use + * @param bridgeApiBaseUrl - The base URL for the bridge API + * @param clientVersion - The client version for metrics (optional) + * @returns A list of bridge tx quotes + */ +export async function fetchBatchSellTrades( + request: BatchSellTradesRequest, + signal: AbortSignal | null, + clientId: string, + jwt: string | undefined, + fetchFn: FetchFunction, + bridgeApiBaseUrl: string, + clientVersion?: string, +): Promise { + const url = `${bridgeApiBaseUrl}/obtainGaslessBatch`; + const batchSellTradesResponse: unknown = await fetchFn(url, { + headers: { + ...getClientHeaders({ + clientId, + clientVersion, + jwt, + }), + 'Content-Type': 'application/json', + }, + signal, + method: 'POST', + body: JSON.stringify(request), + }); + + try { + if (validateBatchSellTradesResponse(batchSellTradesResponse)) { + return batchSellTradesResponse; + } + throw new Error('Invalid batch simulation response'); + } catch (error: unknown) { + // TODO validation failure event + throw new Error(`Invalid batch simulation response. ${error?.toString()}`); + } +} diff --git a/packages/bridge-controller/src/utils/metrics/constants.ts b/packages/bridge-controller/src/utils/metrics/constants.ts index 85de258c85..86c28f63be 100644 --- a/packages/bridge-controller/src/utils/metrics/constants.ts +++ b/packages/bridge-controller/src/utils/metrics/constants.ts @@ -36,6 +36,7 @@ export enum AbortReason { QuoteRequestUpdated = 'Quote Request Updated', ResetState = 'Reset controller state', TransactionSubmitted = 'Transaction submitted', + GaslessTxBatchFetched = 'Gasless transaction batch fetched', } /** diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index f68debdb51..5421c5c144 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -16,6 +16,7 @@ import type { QuoteResponse, NonEvmFees, TxData, + BatchSellTradesResponse, } from '../types'; import { isNativeAddress, isNonEvmChainId } from './bridge'; import { FeatureId } from './validators'; @@ -164,6 +165,25 @@ export const calcSentAmount = ( }; }; +export const calcBatchFees = ( + amount: string, + asset: BridgeAsset, + { exchangeRate, usdExchangeRate }: ExchangeRate, +) => { + const normalizedAmount = calcTokenAmount(amount, asset.decimals); + + return { + amount: normalizedAmount.toString(), + valueInCurrency: exchangeRate + ? normalizedAmount.times(exchangeRate).toString() + : null, + usd: usdExchangeRate + ? normalizedAmount.times(usdExchangeRate).toString() + : null, + asset, + }; +}; + export const calcRelayerFee = ( quoteResponse: QuoteResponse, { exchangeRate, usdExchangeRate }: ExchangeRate, diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index af3f764d80..41f46bf7be 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -18,7 +18,6 @@ import { assert, pattern, intersection, - pick, } from '@metamask/superstruct'; import { CaipAssetTypeStruct, @@ -44,23 +43,26 @@ export enum ActionTypes { REFUEL = 'refuel', } -const HexAddressSchema = define('HexAddress', (v: unknown) => - isValidHexAddress(v as string, { allowNonPrefixed: false }), +const HexAddressSchema = define<`0x${string}`>('HexAddress', (value: unknown) => + isValidHexAddress(value as string, { allowNonPrefixed: false }), ); -const HexStringSchema = define('HexString', (v: unknown) => - isStrictHexString(v as string), +const HexStringSchema = define<`0x${string}`>('HexString', isStrictHexString); + +const NumberStringSchema = define( + 'NumberString', + (value: unknown) => typeof value === 'string' && /^\d+$/u.test(value), ); const VersionStringSchema = define( 'VersionString', - (v: unknown) => - typeof v === 'string' && - /^(\d+\.*){2}\d+$/u.test(v) && - v.split('.').length === 3, + (value: unknown) => + typeof value === 'string' && + /^(\d+\.*){2}\d+$/u.test(value) && + value.split('.').length === 3, ); -export const truthyString = (s: string) => Boolean(s?.length); +export const truthyString = (value: string): boolean => Boolean(value?.length); const TruthyDigitStringSchema = pattern(string(), /^\d+$/u); const ChainIdSchema = number(); @@ -370,9 +372,9 @@ export const IntentSchema = type({ }), }); -export const SimulatedGasFeeLimitsSchema = type({ - maxFeePerGas: string(), - maxPriorityFeePerGas: string(), +export const TxFeeGasLimitsSchema = type({ + maxFeePerGas: NumberStringSchema, + maxPriorityFeePerGas: NumberStringSchema, }); export const GaslessPropertiesSchema = type({ @@ -415,7 +417,7 @@ export const QuoteSchema = intersection([ * src or dest token if the quote has gas fees included or "gasless" */ [FeeType.TX_FEE]: optional( - intersection([FeeDataSchema, SimulatedGasFeeLimitsSchema]), + intersection([FeeDataSchema, TxFeeGasLimitsSchema]), ), }), bridgeId: string(), @@ -533,22 +535,34 @@ export const validateQuoteStreamComplete = ( return true; }; -// TODO should this be here or in the bridge-status-controller? -export const BatchSimulationResponseSchema = type({ +export enum BatchSimulationTransactionType { + TRADE = 'trade', + APPROVAL = 'approval', + TRANSFER = 'transfer', +} + +export const SimulatedGasFeeLimitsSchema = type({ + maxFeePerGas: HexStringSchema, + maxPriorityFeePerGas: HexStringSchema, +}); + +export const BatchSellTradesResponseSchema = type({ transactions: array( intersection([ TxDataSchema, SimulatedGasFeeLimitsSchema, - /** - * The assetId of the srcToken, used to match the simulation response with the quote response - */ - pick(BridgeAssetSchema, ['assetId']), - // updateTransactions won't work for these because we won't know the tx type - type({ type: enums(['swap', 'approval', 'bridge']) }), + type({ type: enums(Object.values(BatchSimulationTransactionType)) }), ]), ), fee: type({ asset: BridgeAssetSchema, - amount: string(), + amount: NumberStringSchema, }), }); + +export const validateBatchSellTradesResponse = ( + data: unknown, +): data is Infer => { + assert(data, BatchSellTradesResponseSchema); + return true; +}; From 812aa2ef25b340dde074316f484fa9a8bed93d77 Mon Sep 17 00:00:00 2001 From: micaelae Date: Wed, 13 May 2026 17:27:54 -0700 Subject: [PATCH 9/9] feat: batch-sell-strategy --- .../bridge-status-controller.test.ts.snap | 155 ++++++++++++++++++ .../src/bridge-status-controller.test.ts | 2 +- .../src/bridge-status-controller.ts | 17 +- .../src/strategy/batch-sell-strategy.ts | 115 +++++++++++++ .../src/strategy/batch-strategy.ts | 107 ++++-------- .../src/strategy/index.ts | 11 ++ .../src/strategy/types.ts | 12 +- .../bridge-status-controller/src/types.ts | 9 + .../src/utils/history.ts | 27 ++- .../src/utils/transaction.test.ts | 18 +- .../src/utils/transaction.ts | 82 ++++----- 11 files changed, 410 insertions(+), 145 deletions(-) create mode 100644 packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts 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 c637c4b116..315093a546 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 @@ -448,6 +448,156 @@ exports[`BridgeStatusController startPollingForBridgeTxStatus stops polling when ] `; +exports[`BridgeStatusController submitTx: EVM batch sell (swap) should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM batch sell (swap) should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567891.456", + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 0, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Main View", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + "txFee": { + "maxFeePerGas": "1395348", + "maxPriorityFeePerGas": "1000001", + }, + }, + "gasIncluded": true, + "gasIncluded7702": false, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + exports[`BridgeStatusController submitTx: EVM batch sell (swap) should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit) 1`] = ` { "chainId": "0xa4b1", @@ -1256,6 +1406,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac "featureId": undefined, "hasApprovalTx": false, "initialDestAssetBalance": undefined, + "isAtomicBatch": true, "isStxEnabled": true, "location": "Main View", "originalTransactionId": "test-tx-id", @@ -1376,6 +1527,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac [ { "atomic": true, + "batchId": undefined, "disable7702": true, "from": "0xaccount1", "isGasFeeIncluded": false, @@ -3731,6 +3883,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle a gasless swap "featureId": undefined, "hasApprovalTx": true, "initialDestAssetBalance": undefined, + "isAtomicBatch": true, "isStxEnabled": true, "location": "Main View", "originalTransactionId": "test-tx-id", @@ -3866,6 +4019,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti "featureId": undefined, "hasApprovalTx": true, "initialDestAssetBalance": undefined, + "isAtomicBatch": true, "isStxEnabled": true, "location": "Main View", "originalTransactionId": "test-tx-id", @@ -3986,6 +4140,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti [ { "atomic": true, + "batchId": undefined, "disable7702": true, "from": "0xaccount1", "isGasFeeIncluded": false, 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 a9ac4dae83..f20173efc0 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -4799,7 +4799,7 @@ describe('BridgeStatusController', () => { ); }); - it.only('should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit)', async () => { + it('should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit)', async () => { setupEventTrackingMocks(mockMessengerCall); // Setup for single tx path - no gas estimation needed since gasIncluded=true mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 9ec2283227..9e65ba5ebb 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -5,7 +5,7 @@ import type { QuoteResponse, Trade, FeatureId, - BatchSimulationResponse, + BatchSellTradesResponse, } from '@metamask/bridge-controller'; import { isNonEvmChainId, @@ -278,11 +278,6 @@ export class BridgeStatusController extends StaticIntervalPollingController, activeAbTests?: { key: string; value: string }[], tokenSecurityTypeDestination?: string | null, - batchSimulationResponse?: BatchSimulationResponse, + batchSellTradesResponse?: BatchSellTradesResponse, ): Promise => { /** * If there are multiple quote responses, we assume that they all originate from the same src chain @@ -1118,7 +1113,7 @@ export class BridgeStatusController extends StaticIntervalPollingController = { messenger: this.messenger, quoteResponses, - batchSimulationResponse, + batchSellTradesResponse, isStxEnabled, isBridgeTx, isDelegatedAccount, @@ -1197,7 +1192,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata)[]; - batchSimulationResponse: BatchSimulationResponse; + batchSellTradesResponse: BatchSellTradesResponse; accountAddress: string; location?: MetaMetricsSwapsEventSource; abTests?: Record; @@ -1215,7 +1210,7 @@ export class BridgeStatusController extends StaticIntervalPollingController, +): AsyncGenerator { + const { + requireApproval, + quoteResponses, + messenger, + addTransactionBatchFn, + isDelegatedAccount, + batchSellTradesResponse, + } = args; + + const tradeData: TradeWithMetadata[] = []; + + const batchId = generateBatchId(); + + const { transactions } = batchSellTradesResponse; + + for (const transaction of transactions.values()) { + const { type, maxFeePerGas, maxPriorityFeePerGas, ...tx } = transaction; + const quoteResponse = quoteResponses.find( + ({ approval, trade }) => + trade?.data.toLowerCase() === tx.data.toLowerCase() || + approval?.data.toLowerCase() === tx.data.toLowerCase(), + ); + + if (type === BatchSimulationTransactionType.TRADE) { + tradeData.push({ + tx, + type: TransactionType.swap, + assetsFiatValues: { + sending: quoteResponse?.sentAmount?.valueInCurrency?.toString(), + receiving: quoteResponse?.toTokenAmount?.valueInCurrency?.toString(), + }, + txFee: { maxFeePerGas, maxPriorityFeePerGas }, + }); + + /* + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: tx.data, + quoteRequestIndex: index, + tradeTxData: quoteResponses[index].trade.data, + approvalTxData: quoteResponses[index].approval?.data, + batchId, + }, + };*/ + } else { + tradeData.push({ + tx, + type: + type === BatchSimulationTransactionType.APPROVAL + ? TransactionType.swapApproval + : TransactionType.tokenMethodTransfer, + txFee: { maxFeePerGas, maxPriorityFeePerGas }, + }); + } + } + + const transactionParams = await getAddTransactionBatchParams({ + messenger, + tradeData, + requireApproval, + isDelegatedAccount, + isAtomic: false, + gasIncluded: quoteResponses[0].quote.gasIncluded, + gasIncluded7702: quoteResponses[0].quote.gasIncluded7702, + gasSponsored: quoteResponses[0].quote.gasSponsored, + batchId, + }); + + const allTransactionMetas = await addTransactionBatch( + messenger, + addTransactionBatchFn, + transactionParams, + ); + for (const [index, tx] of allTransactionMetas.entries()) { + if (tx.tradeMeta) { + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: tx.tradeMeta.id, + quoteRequestIndex: index, + batchId, + approvalTxId: tx.approvalMeta?.id, + bridgeTxMeta: { + id: tx.tradeMeta.id, + hash: tx.tradeMeta.hash, + batchId: tx.tradeMeta.batchId, + }, + }, + }; + } + } +} diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts index 7be4b66959..ff6180e700 100644 --- a/packages/bridge-status-controller/src/strategy/batch-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -3,7 +3,6 @@ import { TransactionType } from '@metamask/transaction-controller'; import { addTransactionBatch, - generateBatchId, getAddTransactionBatchParams, TradeWithMetadata, } from '../utils/transaction'; @@ -26,125 +25,79 @@ export async function* submitBatchHandler( isBridgeTx, addTransactionBatchFn, isDelegatedAccount, - batchSimulationResponse, } = args; - const quoteRequestIndex = 0; - const approvalTxType = isBridgeTx ? TransactionType.bridgeApproval : TransactionType.swapApproval; const tradeData: TradeWithMetadata[] = []; - const batchId = generateBatchId(); - - if (batchSimulationResponse) { - const { transactions } = batchSimulationResponse; - - for (const [index, transaction] of transactions.entries()) { - const { assetId, type, maxFeePerGas, maxPriorityFeePerGas, ...tx } = - transaction; - const quoteResponse = quoteResponses.find( - ({ quote }) => quote.srcAsset.assetId === transactions[0].assetId, - ); - tradeData.push({ - tx, - type: - type === 'swap' ? TransactionType.swap : TransactionType.swapApproval, - // If there is no matching quote response, these will be undefined - assetsFiatValues: - type === 'swap' - ? { - sending: quoteResponse?.sentAmount?.valueInCurrency?.toString(), - receiving: - quoteResponse?.toTokenAmount?.valueInCurrency?.toString(), - } - : undefined, - txFee: { maxFeePerGas, maxPriorityFeePerGas }, - }); - if (type === 'swap') { - yield { - type: SubmitStep.AddHistoryItem, - payload: { - historyKey: tx.data, - quoteRequestIndex: index, - tradeTxData: quoteResponses[index].trade.data, - approvalTxData: quoteResponses[index].approval?.data, - batchId, - }, - }; - } - } - } else { - const quoteResponse = quoteResponses[quoteRequestIndex]; - if (quoteResponse.resetApproval) { - tradeData.push({ - tx: quoteResponse.resetApproval, - type: approvalTxType, - // TODO for regular 7702, shoudl txFee be appended to both approval and trade? - // I think it covers both - txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], - }); - } - if (quoteResponse.approval) { - tradeData.push({ - tx: quoteResponse.approval, - type: approvalTxType, - // TODO rm - txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], - }); - } + const quoteResponse = quoteResponses[0]; + if (quoteResponse.resetApproval) { tradeData.push({ - tx: quoteResponse.trade, - type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, - assetsFiatValues: { - sending: quoteResponse.sentAmount?.valueInCurrency?.toString(), - receiving: quoteResponse.toTokenAmount?.valueInCurrency?.toString(), - }, + tx: quoteResponse.resetApproval, + type: approvalTxType, + // TODO for regular 7702, shoudl txFee be appended to both approval and trade? + // I think it covers both txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], }); } + if (quoteResponse.approval) { + tradeData.push({ + tx: quoteResponse.approval, + type: approvalTxType, + // TODO rm this for approvals? + txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], + }); + } + tradeData.push({ + tx: quoteResponse.trade, + type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, + assetsFiatValues: { + sending: quoteResponse.sentAmount?.valueInCurrency?.toString(), + receiving: quoteResponse.toTokenAmount?.valueInCurrency?.toString(), + }, + txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], + }); + const isAtomicBatch = true; const transactionParams = await getAddTransactionBatchParams({ messenger, tradeData, requireApproval, isDelegatedAccount, - isBatchSell: Boolean(batchSimulationResponse), + isAtomic: isAtomicBatch, gasIncluded: quoteResponses[0].quote.gasIncluded, gasIncluded7702: quoteResponses[0].quote.gasIncluded7702, gasSponsored: quoteResponses[0].quote.gasSponsored, - batchId, }); - const { approvalMeta, tradeMeta } = await addTransactionBatch( + const allTransactionMetas = await addTransactionBatch( messenger, addTransactionBatchFn, transactionParams, ); - - if (!tradeMeta) { - return; - } + const { tradeMeta, approvalMeta } = + allTransactionMetas.find((tx) => tx.tradeMeta) ?? {}; if (tradeMeta) { yield { type: SubmitStep.SetTradeMeta, - payload: { tradeMeta, quoteRequestIndex }, + payload: { tradeMeta }, }; yield { type: SubmitStep.AddHistoryItem, payload: { historyKey: tradeMeta.id, + isAtomicBatch, approvalTxId: approvalMeta?.id, bridgeTxMeta: { id: tradeMeta.id, hash: tradeMeta.hash, batchId: tradeMeta.batchId, }, - quoteRequestIndex, }, }; } diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts index 1300ab6ce4..52ff1f19f3 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,6 +17,7 @@ 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, @@ -43,6 +45,11 @@ const validateParams = < } }; +const validateBatchSellParams = ( + params: SubmitStrategyParams, +): params is SubmitStrategyParams => + Boolean(params.batchSellTradesResponse); + /** * Selects the appropriate submit strategy based on the quote parameters then executes it * @@ -76,6 +83,10 @@ const executeSubmitStrategy = ( ); } + if (validateBatchSellParams(params)) { + return submitBatchSellHandler(params); + } + // Intent transactions if (quoteResponse.quote.intent) { return submitIntentHandler(params); diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index cfaec745b5..fe297e168a 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -1,6 +1,6 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { - BatchSimulationResponse, + BatchSellTradesResponse, BridgeClientId, QuoteMetadata, QuoteResponse, @@ -42,6 +42,7 @@ export type SubmitStepResult = approvalTxData?: string; quoteRequestIndex?: number; batchId?: string; + isAtomicBatch?: boolean; }; } | { @@ -81,8 +82,13 @@ export type SubmitStepResult = /** * The parameters for the submission flow */ -export type SubmitStrategyParams = { - batchSimulationResponse?: BatchSimulationResponse; +export type SubmitStrategyParams< + TradeType extends Trade = TxData, + BatchSellTradesResponseType extends BatchSellTradesResponse | undefined = + | BatchSellTradesResponse + | undefined, +> = { + batchSellTradesResponse: BatchSellTradesResponseType; addTransactionBatchFn: TransactionController['addTransactionBatch']; isBridgeTx: boolean; isDelegatedAccount: boolean; diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 6b01432a4b..28a5645a6f 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -114,6 +114,12 @@ export type BridgeHistoryItem = { */ originalTransactionId?: string; // Keep original transaction ID for intent transactions batchId?: string; + isAtomicBatch?: boolean; + approvalTxData?: string; + /** + * Batch sell trades won't have a txMetaId or actionId on submission so we store the calldata here to match the txs later + */ + tradeTxData?: string; quote: Quote; status: StatusResponse; startTime: number; // timestamp in ms @@ -228,6 +234,9 @@ export type QuoteMetadataSerialized = { export type StartPollingForBridgeTxStatusArgs = { bridgeTxMeta?: Pick; actionId?: string; + isAtomicBatch?: boolean; + approvalTxData?: BridgeHistoryItem['approvalTxData']; + tradeTxData?: BridgeHistoryItem['tradeTxData']; /** * @deprecated the txMeta or orderUid should be used instead */ diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index af227514fa..882540efbd 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -72,13 +72,14 @@ export const getMatchingHistoryEntryForTxMeta = ( status: { srcChain: { txHash }, }, + isAtomicBatch, } = value; return ( key === txMeta.id || key === txMeta.actionId || txMetaId === txMeta.id || (actionId ? actionId === txMeta.actionId : false) || - (batchId ? batchId === txMeta.batchId : false) || + (batchId && isAtomicBatch ? batchId === txMeta.batchId : false) || (txHash ? txHash.toLowerCase() === txMeta.hash?.toLowerCase() : false) ); }); @@ -97,8 +98,13 @@ export const getMatchingHistoryEntryForApprovalTxMeta = ( ): [string, BridgeHistoryItem] | undefined => { const historyEntries = Object.entries(txHistory); - return historyEntries.find(([_, value]) => - value.approvalTxId ? value.approvalTxId === txMeta.id : false, + return historyEntries.find( + ([_, { approvalTxId, approvalTxData }]) => + approvalTxId ? approvalTxId === txMeta.id : false, + // || + // (approvalTxData && txMeta.txParams?.data + // ? approvalTxData === txMeta.txParams.data.toLowerCase() + // : false), ); }; @@ -146,11 +152,14 @@ export const getInitialHistoryItem = ( originalTransactionId, actionId, tokenSecurityTypeDestination, + isAtomicBatch, + approvalTxData, + tradeTxData, } = 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 +205,16 @@ export const getInitialHistoryItem = ( }), }; + if (approvalTxData) { + txHistoryItem.approvalTxData = approvalTxData.toLowerCase(); + } + if (tradeTxData) { + txHistoryItem.tradeTxData = tradeTxData.toLowerCase(); + } + if (isAtomicBatch) { + txHistoryItem.isAtomicBatch = isAtomicBatch; + } + return txHistoryItem; }; diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index 56e6ffcb7d..773105ad54 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -2204,7 +2204,7 @@ describe('Bridge Status Controller Transaction Utils', () => { findAndUpdateTransactionsInBatch({ messenger: mockMessagingSystem, batchId, - txDataByType, + txDataByType: [txDataByType], }); expect( @@ -2263,7 +2263,7 @@ describe('Bridge Status Controller Transaction Utils', () => { findAndUpdateTransactionsInBatch({ messenger: mockMessenger as unknown as BridgeStatusControllerMessenger, batchId, - txDataByType, + txDataByType: [txDataByType], }); // Should identify and update 7702 transaction with delegationAddress @@ -2306,7 +2306,7 @@ describe('Bridge Status Controller Transaction Utils', () => { findAndUpdateTransactionsInBatch({ messenger: mockMessenger as unknown as BridgeStatusControllerMessenger, batchId, - txDataByType, + txDataByType: [txDataByType], }); // Should match 7702 approval transaction by data @@ -2357,7 +2357,7 @@ describe('Bridge Status Controller Transaction Utils', () => { findAndUpdateTransactionsInBatch({ messenger: mockMessenger as unknown as BridgeStatusControllerMessenger, batchId, - txDataByType, + txDataByType: [txDataByType], }); // Should update regular transactions by matching data @@ -2413,7 +2413,7 @@ describe('Bridge Status Controller Transaction Utils', () => { findAndUpdateTransactionsInBatch({ messenger: mockMessagingSystem, batchId, - txDataByType, + txDataByType: [txDataByType], }); // Should not update transactions with different batchId @@ -2444,7 +2444,7 @@ describe('Bridge Status Controller Transaction Utils', () => { const result = findAndUpdateTransactionsInBatch({ messenger: mockMessagingSystem, batchId, - txDataByType, + txDataByType: [txDataByType], }); // Should match since 7702 bridge transactions use batch type @@ -2461,7 +2461,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }, 'Update tx type to bridge', ); - expect(result.tradeMeta).toStrictEqual( + expect(result[0].tradeMeta).toStrictEqual( expect.objectContaining({ id: 'tx1', type: TransactionType.bridge }), ); }); @@ -2487,7 +2487,7 @@ describe('Bridge Status Controller Transaction Utils', () => { const result = findAndUpdateTransactionsInBatch({ messenger: mockMessagingSystem, batchId, - txDataByType, + txDataByType: [txDataByType], }); expect(mockMessagingSystem.call).toHaveBeenCalledWith( @@ -2503,7 +2503,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }, 'Update tx type to bridgeApproval', ); - expect(result.approvalMeta).toStrictEqual( + expect(result[0].approvalMeta).toStrictEqual( expect.objectContaining({ id: 'tx1', type: TransactionType.bridgeApproval, diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 3b143ed815..ae5d395bcf 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -10,6 +10,7 @@ import type { QuoteResponse, SimulatedGasFeeLimits, TxData, + TxFeeGasLimits, } from '@metamask/bridge-controller'; import { toHex } from '@metamask/controller-utils'; import { @@ -23,7 +24,7 @@ import type { TransactionBatchSingleRequest, TransactionParams, } from '@metamask/transaction-controller'; -import { createProjectLogger, Hex } from '@metamask/utils'; +import { createProjectLogger, Hex, isStrictHexString } from '@metamask/utils'; import { APPROVAL_DELAY_MS } from '../constants'; import type { BridgeStatusControllerMessenger } from '../types'; @@ -57,14 +58,14 @@ const appendGasFees = async ( networkClientId: string, chainId: Hex, isGasIncluded7702: boolean, - simulatedGasFeeLimits?: SimulatedGasFeeLimits, + simulatedGasFeeLimits?: SimulatedGasFeeLimits | TxFeeGasLimits, ) => { const normalizedTrade = { ...trade, - to: trade.to as `0x${string}`, - from: trade.from as `0x${string}`, - value: trade.value as `0x${string}`, - data: trade.data as `0x${string}`, + to: trade.to, + from: trade.from, + value: trade.value, + data: trade.data, }; if (isGasIncluded7702) { return normalizedTrade; @@ -79,8 +80,14 @@ const appendGasFees = async ( if (simulatedGasFeeLimits) { return { ...transactionParams, - maxFeePerGas: toHex(simulatedGasFeeLimits.maxFeePerGas), - maxPriorityFeePerGas: toHex(simulatedGasFeeLimits.maxPriorityFeePerGas), + maxFeePerGas: isStrictHexString(simulatedGasFeeLimits.maxFeePerGas) + ? simulatedGasFeeLimits.maxFeePerGas + : toHex(simulatedGasFeeLimits.maxFeePerGas), + maxPriorityFeePerGas: isStrictHexString( + simulatedGasFeeLimits.maxPriorityFeePerGas, + ) + ? simulatedGasFeeLimits.maxPriorityFeePerGas + : toHex(simulatedGasFeeLimits.maxPriorityFeePerGas), }; } @@ -305,14 +312,14 @@ export type TradeWithMetadata = { tx: TxData & Partial; type: TransactionType; assetsFiatValues?: { sending?: string; receiving?: string }; - txFee?: SimulatedGasFeeLimits; + txFee?: SimulatedGasFeeLimits | TxFeeGasLimits; }; export const getAddTransactionBatchParams = async ({ messenger, tradeData, requireApproval = false, - isBatchSell, + isAtomic, gasIncluded7702, gasSponsored, gasIncluded, @@ -323,7 +330,7 @@ export const getAddTransactionBatchParams = async ({ tradeData: TradeWithMetadata[]; requireApproval?: boolean; isDelegatedAccount: boolean; - isBatchSell?: boolean; + isAtomic?: boolean; batchId?: Hex; }): Promise[0]> => { const trade = tradeData[0].tx; @@ -380,7 +387,7 @@ export const getAddTransactionBatchParams = async ({ origin: 'metamask', from: selectedAccount.address as Hex, transactions, - atomic: !isBatchSell, + atomic: isAtomic, batchId, }; }; @@ -392,21 +399,21 @@ export const findAndUpdateTransactionsInBatch = ({ }: { messenger: BridgeStatusControllerMessenger; batchId: string; - txDataByType: { [key in TransactionType]?: string }; + txDataByType: { [key in TransactionType]?: string }[]; }) => { const txs = getTransactions(messenger); const txBatch: { approvalMeta?: TransactionMeta; tradeMeta?: TransactionMeta; - } = { - approvalMeta: undefined, - tradeMeta: undefined, - }; + }[] = []; // This is a workaround to update the tx type after the tx is signed // TODO: remove this once the tx type for batch txs is preserved in the tx controller - const txEntries = Object.entries(txDataByType) as [TransactionType, string][]; - txEntries.forEach(([txType, txData]) => { + const txEntries = txDataByType.flatMap( + (list) => Object.entries(list) as [TransactionType, string][], + ); + txEntries.forEach(([txType, txData], index) => { + txBatch[index] ??= {}; // Skip types not present in the batch (e.g. swap entry is undefined for bridge txs) if (txData === undefined) { return; @@ -453,7 +460,7 @@ export const findAndUpdateTransactionsInBatch = ({ TransactionType.bridgeApproval, TransactionType.swapApproval, ] as readonly string[]; - txBatch[txTypes.includes(txType) ? 'approvalMeta' : 'tradeMeta'] = + txBatch[index][txTypes.includes(txType) ? 'approvalMeta' : 'tradeMeta'] = updatedTx; } }); @@ -466,40 +473,35 @@ export const addTransactionBatch = async ( addTransactionBatchFn: TransactionController['addTransactionBatch'], args: Parameters[0], ) => { - const txDataByType = { - [TransactionType.bridgeApproval]: args.transactions.find( - ({ type }) => type === TransactionType.bridgeApproval, - )?.params.data, - [TransactionType.swapApproval]: args.transactions.find( - ({ type }) => type === TransactionType.swapApproval, - )?.params.data, - [TransactionType.bridge]: args.transactions.find( - ({ type }) => type === TransactionType.bridge, - )?.params.data, - [TransactionType.swap]: args.transactions.find( - ({ type }) => type === TransactionType.swap, - )?.params.data, - }; + const txDataByType: { [key in TransactionType]?: string }[] = []; + let index = 0; + args.transactions.forEach(({ type, params }) => { + txDataByType[index] ??= {}; + if (type && isCrossChainTx(type)) { + txDataByType[index][type] = params.data; + if (isTradeTx(type)) { + index += 1; + } + } + }); const { batchId } = await addTransactionBatchFn(args); - if (!args.atomic) { - return { batchId }; - } - - const { approvalMeta, tradeMeta } = findAndUpdateTransactionsInBatch({ + const allTransactionMetas = findAndUpdateTransactionsInBatch({ messenger, batchId, txDataByType, }); + const tradeMeta = allTransactionMetas.find((tx) => tx.tradeMeta); + if (!tradeMeta) { throw new Error( 'Failed to update cross-chain swap transaction batch: tradeMeta not found', ); } - return { approvalMeta, tradeMeta }; + return allTransactionMetas; }; /**