diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index b270e82a20..23e56d30fc 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `gasIncluded` and `gasIncluded7702` to `BatchSellTradesResponseSchema` ([#8775](https://github.com/MetaMask/core/pull/8775)) - Add optional `has_sufficient_gas_for_quote` property to `QuotesReceived` event and `getQuotesReceivedProperties` utility to allow clients to pass whether the user has sufficient gas to submit the quote ([#8895](https://github.com/MetaMask/core/pull/8895)) ### Changed @@ -28,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/accounts-controller` from `^38.1.1` to `^38.1.2` ([#8912](https://github.com/MetaMask/core/pull/8912)) - Bump `@metamask/profile-sync-controller` from `^28.1.0` to `^28.1.1` ([#8912](https://github.com/MetaMask/core/pull/8912)) +### Removed + +- **BREAKING**: Deprecate `BridgeUserAction` and `BridgeBackgroundAction` enums ([#8775](https://github.com/MetaMask/core/pull/8775)) + ## [73.0.1] ### Changed diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 52fb833ab7..e1b3b447e8 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -83,8 +83,6 @@ export { SortOrder, ChainId, RequestStatus, - BridgeUserAction, - BridgeBackgroundAction, type TokenFeature, type QuoteStreamCompleteData, type BridgeControllerGetStateAction, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index c9f96fad49..cd5e52ad93 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -361,27 +361,6 @@ export enum RequestStatus { ERROR = 2, } -/** - * @deprecated Use the separate method action types (e.g., - * `BridgeControllerFetchQuotesAction`) instead. - */ -export enum BridgeUserAction { - SELECT_DEST_NETWORK = 'selectDestNetwork', - UPDATE_QUOTE_PARAMS = 'updateBridgeQuoteRequestParams', -} - -/** - * @deprecated Use the separate method action types (e.g., - * `BridgeControllerFetchQuotesAction`) instead. - */ -export enum BridgeBackgroundAction { - SET_CHAIN_INTERVAL_LENGTH = 'setChainIntervalLength', - RESET_STATE = 'resetState', - TRACK_METAMETRICS_EVENT = 'trackUnifiedSwapBridgeEvent', - STOP_POLLING_FOR_QUOTES = 'stopPollingForQuotes', - FETCH_QUOTES = 'fetchQuotes', -} - export type BridgeControllerState = { quoteRequest: Partial[]; quotes: (QuoteResponse & L1GasFees & NonEvmFees)[]; diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index e52d85373d..a550e7190d 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -155,10 +155,10 @@ export const isCustomSlippage = (slippage: GenericQuoteRequest['slippage']) => { }; export const getQuotesReceivedProperties = ( - activeQuote: null | (QuoteResponse & Partial), + activeQuote: null | (QuoteResponse & QuoteMetadata), warnings: QuoteWarning[] = [], isSubmittable: boolean = true, - recommendedQuote?: null | (QuoteResponse & Partial), + recommendedQuote?: null | (QuoteResponse & QuoteMetadata), usdBalanceSource?: number, hasSufficientGasForQuote?: boolean | null, ) => { diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 33d6a5edd9..06030fb11e 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -547,21 +547,24 @@ export const SimulatedGasFeeLimitsSchema = type({ maxPriorityFeePerGas: HexStringSchema, }); -export const BatchSellTradesResponseSchema = type({ - transactions: array( - intersection([ - TxDataSchema, - SimulatedGasFeeLimitsSchema, - type({ type: enums(Object.values(BatchSellTransactionType)) }), - ]), - ), - fee: optional( - type({ - asset: BridgeAssetSchema, - amount: NumberStringSchema, - }), - ), -}); +export const BatchSellTradesResponseSchema = intersection([ + type({ + transactions: array( + intersection([ + TxDataSchema, + SimulatedGasFeeLimitsSchema, + type({ type: enums(Object.values(BatchSellTransactionType)) }), + ]), + ), + fee: optional( + type({ + asset: BridgeAssetSchema, + amount: NumberStringSchema, + }), + ), + }), + GaslessPropertiesSchema, +]); export const validateBatchSellTradesResponse = ( data: unknown, diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 8617b4856a..cf88ede055 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,8 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Implement `submitBatchSell` method to submit BatchSell transactions to the TransactionController via STX or 7702. This requires clients to add `BridgeControllerGetStateAction` as an allowed action ([#8775](https://github.com/MetaMask/core/pull/8775)) +- Wire up post-submission BatchSell history ([#8775](https://github.com/MetaMask/core/pull/8775)) + - Create a history item for each STX trade in a batch, with the same batchId (key by `txMeta.id`) + - Create a history item for each trade submitted through a 7702 batch (key by `quoteId`). These won't have a reference to the batchId, and will only include quote and fee data + - Create a history item for the 7702 batch's delegation tx (key by `txMeta.id`). BatchSell delegation transactions include a list of `quoteIds` to associate the corresponding BatchSell trades with the delegation tx + - Expose `getBatchSellHistoryItemsForTxHash` util that returns history items matching either a delegation tx hash or an STX hash + - Expose `isBatchSellHistoryItem` util that returns whether a history item is a BatchSell operation + ### Changed +- Update controller and submit strategies to support an array of quotes instead of a single one ([#8775](https://github.com/MetaMask/core/pull/8775)) - Refactor tx submission into strategies to reduce quote-specific branching in the controller, and to de-duplicate shared logic between `submitTx` and `submitIntent`. Each strategy yields payloads that the controller uses to update history, poll, and publish metrics ([#8257](https://github.com/MetaMask/core/pull/8257)) - Bump `@metamask/bridge-controller` from `^73.0.1` to `^73.1.0` ([#8915](https://github.com/MetaMask/core/pull/8915)) - Refactor batch transaction utils to handle multiple quote requests within a batch (for BatchSell integration) ([#8886](https://github.com/MetaMask/core/pull/8886)) @@ -16,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Use txFee from the bridge-api whenever it's provided ([#8805](https://github.com/MetaMask/core/pull/8805)) +- Save swap failure/completion time to txHistory to populate `actual_time_minutes` event property ([#8805](https://github.com/MetaMask/core/pull/8805)) ## [71.2.1] 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 cd2e50002a..9bd3c24076 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 @@ -6222,7 +6222,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran { "account_hardware_type": null, "action_type": "swapbridge-v1", - "actual_time_minutes": 0, + "actual_time_minutes": 833734.9086166667, "allowance_reset_transaction": undefined, "approval_transaction": undefined, "chain_id_destination": "eip155:42161", @@ -6265,7 +6265,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran { "account_hardware_type": null, "action_type": "swapbridge-v1", - "actual_time_minutes": 0, + "actual_time_minutes": 833734.9086166667, "allowance_reset_transaction": undefined, "approval_transaction": undefined, "chain_id_destination": "eip155:10", @@ -6314,7 +6314,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran { "account_hardware_type": null, "action_type": "swapbridge-v1", - "actual_time_minutes": 0, + "actual_time_minutes": 833734.9086166667, "allowance_reset_transaction": undefined, "approval_transaction": undefined, "chain_id_destination": "eip155:10", @@ -6365,7 +6365,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran { "account_hardware_type": null, "action_type": "swapbridge-v1", - "actual_time_minutes": 0, + "actual_time_minutes": 833734.9086166667, "allowance_reset_transaction": undefined, "approval_transaction": "COMPLETE", "chain_id_destination": "eip155:10", @@ -6458,7 +6458,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran { "account_hardware_type": null, "action_type": "swapbridge-v1", - "actual_time_minutes": 0, + "actual_time_minutes": 833734.9086166667, "allowance_reset_transaction": undefined, "approval_transaction": undefined, "chain_id_destination": "eip155:42161", @@ -6502,7 +6502,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran { "account_hardware_type": null, "action_type": "swapbridge-v1", - "actual_time_minutes": 0, + "actual_time_minutes": 833734.9086166667, "allowance_reset_transaction": undefined, "approval_transaction": "COMPLETE", "chain_id_destination": "eip155:10", diff --git a/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts b/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts index 1e885f0d41..d6a4aaf648 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts @@ -40,6 +40,11 @@ export type BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction = { handler: BridgeStatusController['getBridgeHistoryItemByTxMetaId']; }; +export type BridgeStatusControllerSubmitBatchSellAction = { + type: `BridgeStatusController:submitBatchSell`; + handler: BridgeStatusController['submitBatchSell']; +}; + /** * Union of all BridgeStatusController action types. */ @@ -50,4 +55,5 @@ export type BridgeStatusControllerMethodActions = | BridgeStatusControllerSubmitTxAction | BridgeStatusControllerSubmitIntentAction | BridgeStatusControllerRestartPollingForFailedAttemptsAction - | BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction; + | BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction + | BridgeStatusControllerSubmitBatchSellAction; diff --git a/packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts new file mode 100644 index 0000000000..b47a325714 --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts @@ -0,0 +1,821 @@ +import type { + BridgeControllerMessenger, + TxData, + BatchSellTradesResponse, + Quote, +} from '@metamask/bridge-controller'; +import { BatchSellTransactionType } from '@metamask/bridge-controller'; +import { toHex } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; + +import { + getHistoryItem, + getTxMetasForBatch, + mockBatchSellErc20Erc20, + mockBatchSellTradesErc20Erc20, +} from '../test/mock-batch-sell-erc20-erc20'; +import { BridgeStatusController } from './bridge-status-controller'; +import { BRIDGE_STATUS_CONTROLLER_NAME } from './constants'; +import { BridgeClientId } from './types'; +import type { + BridgeHistoryItem, + BridgeStatusControllerMessenger, +} from './types'; +import { getBatchSellHistoryItemsForTxHash } from './utils/history'; +import { shouldDisable7702 } from './utils/transaction'; + +type AllBridgeStatusControllerActions = + MessengerActions; + +type AllBridgeStatusControllerEvents = + MessengerEvents; + +type AllBridgeControllerActions = MessengerActions; + +type AllBridgeControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllBridgeStatusControllerActions | AllBridgeControllerActions, + AllBridgeStatusControllerEvents | AllBridgeControllerEvents +>; + +const addTransactionBatchFn = jest.fn(); + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function getControllerMessenger( + rootMessenger: RootMessenger, +): BridgeStatusControllerMessenger { + const messenger = new Messenger({ + namespace: BRIDGE_STATUS_CONTROLLER_NAME, + parent: rootMessenger, + }) as unknown as BridgeStatusControllerMessenger; + rootMessenger.delegate({ + messenger, + actions: [ + 'AccountsController:getAccountByAddress', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'SnapController:handleRequest', + 'TransactionController:getState', + 'TransactionController:updateTransaction', + 'TransactionController:addTransaction', + 'TransactionController:estimateGasFee', + 'TransactionController:isAtomicBatchSupported', + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'BridgeController:stopPollingForQuotes', + 'BridgeController:getState', + 'RemoteFeatureFlagController:getState', + 'AuthenticationController:getBearerToken', + 'KeyringController:signTypedMessage', + ], + events: ['TransactionController:transactionStatusUpdated'], + }); + return messenger; +} + +type WithControllerCallback = (payload: { + controller: BridgeStatusController; + rootMessenger: RootMessenger; + messenger: BridgeStatusControllerMessenger; + startPollingForBridgeTxStatusSpy: jest.Mock; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; + mockMessengerCall?: jest.Mock; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {}, mockMessengerCall = undefined }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const messenger = getControllerMessenger(rootMessenger); + if (mockMessengerCall) { + jest.spyOn(messenger, 'call').mockImplementation(mockMessengerCall); + } + const controller = new BridgeStatusController({ + messenger, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + addTransactionBatchFn, + ...options, + }); + const startPollingForBridgeTxStatusSpy = jest.fn(); + if (mockMessengerCall) { + jest + .spyOn(controller, 'startPolling') + .mockImplementation(startPollingForBridgeTxStatusSpy); + } + return await testFunction({ + controller, + rootMessenger, + messenger, + startPollingForBridgeTxStatusSpy, + }); +} + +// Define mocks at the top level +const mockSelectedAccount = { + id: 'test-account-id', + address: '0xaccount1', + type: 'eth', + metadata: { + keyring: { + type: ['any'], + }, + }, +}; +const batchId = '0xBatchId1'; +const mockQuotes = mockBatchSellErc20Erc20.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + // BatchSell quotes have no gasless params because they are not simulated + gasIncluded7702: undefined, + gasIncluded: undefined, + gasSponsored: undefined, + }, + sentAmount: { + usd: '100', + valueInCurrency: '200', + }, + toTokenAmount: { + usd: '101', + valueInCurrency: '201', + }, +})); +const mockTransferTx: BatchSellTradesResponse['transactions'][number] = { + chainId: 10, + from: '0xaccount1', + to: '0xaccount2', + value: '0x1', + data: '0x1', + gasLimit: 100000, + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + type: BatchSellTransactionType.TRANSFER, +}; + +describe('BridgeStatusController', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('submitBatchSell', () => { + let mockMessengerCall: jest.Mock; + let dateNowSpy: jest.SpyInstance; + + describe.each([true, false])('when gasTransferRequired=%s,', (transfer) => { + const transferTx = transfer ? mockTransferTx : undefined; + + describe.each([true, false])('gasIncluded7702=%s,', (gasIncluded7702) => { + describe.each([true, false])('gasIncluded=%s,', (gasIncluded) => { + describe.each([true, false])('stxEnabled=%s,', (stxEnabled) => { + beforeEach(() => { + jest.clearAllMocks(); + mockMessengerCall = jest.fn(); + dateNowSpy = jest.spyOn(Date, 'now'); + dateNowSpy.mockReturnValueOnce(1779922715705); + dateNowSpy.mockReturnValueOnce(1779922719705); + dateNowSpy.mockReturnValueOnce(1779988819705); + dateNowSpy.mockReturnValueOnce(1779988919705); + }); + + it.each([true, false])( + 'isDelegatedAccount=%s', + async (isDelegatedAccount) => { + if ( + !( + !gasIncluded7702 && + !gasIncluded && + !stxEnabled && + !isDelegatedAccount + ) + ) { + // return; + } + const is7702 = !shouldDisable7702( + gasIncluded7702, + gasIncluded, + isDelegatedAccount, + ); + + // Get the mock tx metas for the batch, either a single tx or multiple + const mockTxMetas = getTxMetasForBatch({ + batchId, + is7702, + }); + + // Append the transfer tx if it is provided + const mockBatchSellTrades = { + ...mockBatchSellTradesErc20Erc20, + gasIncluded7702, + gasIncluded, + transactions: [ + ...mockBatchSellTradesErc20Erc20.transactions, + transferTx, + ].filter((tx) => tx !== undefined), + }; + + // Mock messenger calls + addTransactionBatchFn.mockResolvedValueOnce({ + batchId, + }); + mockMessengerCall.mockReturnValueOnce({ + batchSellTrades: mockBatchSellTrades, + }); + // stopPollingForQuotes + mockMessengerCall.mockImplementationOnce(jest.fn()); + // track event + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); + // isAtomicBatchSupported + mockMessengerCall.mockReturnValueOnce( + isDelegatedAccount + ? [ + { + isSupported: true, + delegationAddress: '0x0', + }, + ] + : [], + ); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('networkClientId'); + mockMessengerCall.mockReturnValueOnce({ + transactions: mockTxMetas.map((txMeta) => ({ + ...txMeta, + })), + }); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitBatchSell', + { + accountAddress: (mockQuotes[0].trade as TxData).from, + quoteResponses: mockQuotes, + isStxEnabled: stxEnabled, + }, + ); + controller.stopAllPolling(); + + // First txMeta should be returned + expect(result).toStrictEqual(mockTxMetas[0]); + + // Verify the messenger calls + expect(mockMessengerCall.mock.calls).toStrictEqual([ + ['BridgeController:getState'], + [ + 'BridgeController:stopPollingForQuotes', + 'Transaction submitted', + undefined, + ], + [ + 'AccountsController:getAccountByAddress', + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + ], + [ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Submitted', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + chain_id_destination: 'eip155:10', + chain_id_source: 'eip155:10', + custom_slippage: false, + gas_included: gasIncluded, + gas_included_7702: gasIncluded7702, + is_hardware_wallet: false, + location: 'Main View', + price_impact: 0, + provider: 'socket_across', + quoted_time_minutes: 1, + stx_enabled: stxEnabled, + swap_type: 'single_chain', + token_address_destination: + 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + token_address_source: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + token_security_type_destination: null, + token_symbol_destination: 'USDC', + token_symbol_source: 'USDC', + usd_amount_source: 100, + usd_quoted_gas: 0, + usd_quoted_return: 0, + }, + ], + [ + 'TransactionController:isAtomicBatchSupported', + { + address: '0xaccount1', + chainIds: ['0xa'], + }, + ], + [ + 'AccountsController:getAccountByAddress', + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + ], + ['NetworkController:findNetworkClientIdByChainId', '0xa'], + ['TransactionController:getState'], + ]); + + const { transactions, ...batchParams } = + addTransactionBatchFn.mock.calls[0][0]; + + // addTransactionBatch options + expect(batchParams).toStrictEqual({ + disable7702: !is7702, + excludeNativeTokenForFee: Boolean(transferTx), + atomic: false, + from: '0xaccount1', + isGasFeeIncluded: gasIncluded7702, + isGasFeeSponsored: undefined, + isInternal: true, + networkClientId: 'networkClientId', + origin: 'metamask', + requireApproval: false, + skipInitialGasEstimate: false, + }); + + expect(transactions).toStrictEqual( + mockBatchSellTrades.transactions.map( + ({ type, gasLimit, chainId, ...tx }) => ({ + params: { + ...tx, + gas: toHex(Number(gasLimit)), + }, + type: + // eslint-disable-next-line no-nested-ternary + type === BatchSellTransactionType.TRADE + ? TransactionType.swap + : type === BatchSellTransactionType.APPROVAL + ? TransactionType.swapApproval + : TransactionType.tokenMethodTransfer, + assetsFiatValues: + type === BatchSellTransactionType.TRADE + ? { + sending: + mockQuotes[0].sentAmount?.valueInCurrency?.toString(), + receiving: + mockQuotes[0].toTokenAmount?.valueInCurrency?.toString(), + } + : undefined, + }), + ), + ); + + // Verify the initial history item + expect(result.id).toStrictEqual(mockTxMetas[0].id); + + const historyItem = controller.state.txHistory[result.id]; + + const expectedHistoryItem = getHistoryItem({ + isStxEnabled: stxEnabled, + batchSellData: mockBatchSellTrades, + txMetaId: result.id, + quote: { + ...mockQuotes[0].quote, + // Gas params should be merged to the initial quote + gasIncluded, + gasIncluded7702, + gasSponsored: false, + }, + + quoteIds: is7702 + ? // 7702 batch should have a list of quoteIds + [ + mockQuotes[0].quote.requestId, + mockQuotes[1].quote.requestId, + ] + : undefined, + }); + expect(historyItem).toStrictEqual(expectedHistoryItem); + + const expectedHistoryItems = []; + const quoteHistoryItem = ( + quoteObject: Quote, + ): Partial => ({ + batchId: undefined, + featureId: undefined, + slippagePercentage: 0, + txMetaId: undefined, + actionId: undefined, + approvalTxId: undefined, + isStxEnabled: stxEnabled, + batchSellData: mockBatchSellTrades, + quote: { + ...quoteObject, + gasIncluded, + gasIncluded7702, + gasSponsored: false, + }, + }); + + // Add a txHistory item for each 7702 quote + for (const [ + index, + ] of expectedHistoryItem.quoteIds?.entries() ?? []) { + const quoteItem = quoteHistoryItem( + mockQuotes[index].quote, + ); + + expectedHistoryItems.push( + expect.objectContaining(quoteItem), + ); + } + + // Add a txHistory item for each STX swap tx + const stxSwapTxMetas = mockTxMetas.filter( + ({ type }) => type === TransactionType.swap, + ); + expect(stxSwapTxMetas).toHaveLength(is7702 ? 0 : 2); + for (const [index, txMeta] of stxSwapTxMetas.entries()) { + const quoteItem = { + ...quoteHistoryItem(mockQuotes[index].quote), + batchId: txMeta.batchId, + txMetaId: txMeta.id, + }; + expectedHistoryItems.push( + expect.objectContaining(quoteItem), + ); + } + + expect(expectedHistoryItems.length).toBeGreaterThan(1); + + // STX tx hash is not stored initially, so use the txMeta.id instead + const { historyItems, is7702Batch } = + getBatchSellHistoryItemsForTxHash( + controller.state.txHistory, + mockTxMetas[0].id, + ); + + expect(is7702Batch).toBe(is7702); + expect(historyItems).toHaveLength( + expectedHistoryItems.length, + ); + expect(historyItems).toStrictEqual(expectedHistoryItems); + + // No history items should be returned if no txHashOrId is provided + expect( + getBatchSellHistoryItemsForTxHash( + controller.state.txHistory, + ).historyItems, + ).toStrictEqual([]); + + // Test confirmation subscription + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce({ + transactions: mockTxMetas.map((txMeta) => ({ + ...txMeta, + })), + }); + + // Publish confirmation event for swap + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + ...mockTxMetas[0], + status: TransactionStatus.confirmed, + }, + }, + ); + + expect( + getBatchSellHistoryItemsForTxHash( + controller.state.txHistory, + mockTxMetas[0].hash, + ).historyItems, + ).toStrictEqual(expectedHistoryItems); + + // Verify the messenger calls + expect( + mockMessengerCall.mock.calls.slice(-3), + ).toStrictEqual([ + ['AccountsController:getAccountByAddress', '0xaccount1'], + ['TransactionController:getState'], + [ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Completed', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + // actual_time_minutes: expect.closeTo(29644790, -1), + actual_time_minutes: expect.any(Number), + allowance_reset_transaction: undefined, + approval_transaction: 'COMPLETE', + chain_id_destination: 'eip155:10', + chain_id_source: 'eip155:10', + custom_slippage: true, + destination_transaction: 'PENDING', + gas_included: gasIncluded, + gas_included_7702: gasIncluded7702, + is_hardware_wallet: false, + location: 'Main View', + price_impact: 0, + provider: 'socket_across', + quote_vs_execution_ratio: 0, + quoted_time_minutes: 1, + quoted_vs_used_gas_ratio: 0, + security_warnings: [], + slippage_limit: 0, + source_transaction: 'COMPLETE', + stx_enabled: stxEnabled, + swap_type: 'single_chain', + token_address_destination: + 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + token_address_source: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + token_security_type_destination: null, + token_symbol_destination: 'USDC', + token_symbol_source: 'USDC', + usd_amount_source: 100, + usd_actual_gas: 0, + usd_actual_return: 0, + usd_quoted_gas: 0, + usd_quoted_return: 101, + }, + ], + ]); + + expect( + startPollingForBridgeTxStatusSpy, + ).toHaveBeenCalledTimes(0); + + // Test failure subscription + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce({ + transactions: mockTxMetas.map((txMeta) => ({ + ...txMeta, + })), + }); + + // Publish failed event for swap + const failedTxMeta = mockTxMetas[2] ?? mockTxMetas[0]; + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + ...failedTxMeta, + status: TransactionStatus.failed, + }, + }, + ); + + expect( + getBatchSellHistoryItemsForTxHash( + controller.state.txHistory, + failedTxMeta.hash, + ).historyItems, + ).toStrictEqual(expectedHistoryItems); + + // Verify the messenger calls + expect(mockMessengerCall.mock.calls.at(-1)).toStrictEqual([ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Failed', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + actual_time_minutes: expect.closeTo( + is7702 ? 1103 : 0, + -1, + ), + allowance_reset_transaction: undefined, + approval_transaction: 'COMPLETE', + chain_id_destination: 'eip155:10', + chain_id_source: 'eip155:10', + custom_slippage: true, + destination_transaction: 'FAILED', + error_message: 'Transaction failed', + gas_included: gasIncluded, + gas_included_7702: gasIncluded7702, + is_hardware_wallet: false, + location: 'Main View', + price_impact: 0, + provider: is7702 + ? 'socket_across' + : 'socket_celercircle', + quote_vs_execution_ratio: 0, + quoted_time_minutes: is7702 ? 1 : 26, + quoted_vs_used_gas_ratio: 0, + security_warnings: [], + slippage_limit: 0, + source_transaction: 'COMPLETE', + stx_enabled: stxEnabled, + swap_type: 'single_chain', + token_address_destination: + 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + token_address_source: is7702 + ? 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85' + : 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff81', + token_security_type_destination: null, + token_symbol_destination: 'USDC', + token_symbol_source: is7702 ? 'USDC' : 'USDT', + usd_amount_source: 100, + usd_actual_gas: 0, + usd_actual_return: 0, + usd_quoted_gas: 0, + usd_quoted_return: 101, + }, + ]); + + expect( + startPollingForBridgeTxStatusSpy, + ).toHaveBeenCalledTimes(0); + expect(dateNowSpy).toHaveBeenCalledTimes(4); + }, + ); + }, + ); + }); + }); + }); + }); + + it('returns undefined if there is no matching txMeta for the batch', async () => { + const gasIncluded7702 = true; + const gasIncluded = false; + const isDelegatedAccount = true; + const stxEnabled = false; + + // Append the transfer tx if it is provided + const mockBatchSellTrades = { + ...mockBatchSellTradesErc20Erc20, + gasIncluded7702, + gasIncluded, + transactions: [ + mockTransferTx, + ...mockBatchSellTradesErc20Erc20.transactions, + ].filter((tx) => tx !== undefined), + }; + + // Mock messenger calls + addTransactionBatchFn.mockResolvedValueOnce({ + batchId, + }); + mockMessengerCall.mockReturnValueOnce({ + batchSellTrades: mockBatchSellTrades, + }); + // stopPollingForQuotes + mockMessengerCall.mockImplementationOnce(jest.fn()); + // track event + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); + // isAtomicBatchSupported + mockMessengerCall.mockReturnValueOnce( + isDelegatedAccount + ? [ + { + isSupported: true, + delegationAddress: '0x0', + }, + ] + : [], + ); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('networkClientId'); + mockMessengerCall.mockReturnValueOnce({ + transactions: [], + }); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const expectedHistory = controller.state.txHistory; + const result = await expect( + rootMessenger.call('BridgeStatusController:submitBatchSell', { + accountAddress: (mockQuotes[0].trade as TxData).from, + quoteResponses: mockQuotes, + isStxEnabled: stxEnabled, + }), + ).rejects.toThrow( + 'Failed to add BatchSell trade to history: txMeta not found', + ); + controller.stopAllPolling(); + + // First txMeta should be returned + expect(result).toBeUndefined(); + + // Verify the messenger calls + expect(mockMessengerCall.mock.calls).toStrictEqual([ + ['BridgeController:getState'], + [ + 'BridgeController:stopPollingForQuotes', + 'Transaction submitted', + undefined, + ], + [ + 'AccountsController:getAccountByAddress', + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + ], + [ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Submitted', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + chain_id_destination: 'eip155:10', + chain_id_source: 'eip155:10', + custom_slippage: false, + gas_included: gasIncluded, + gas_included_7702: gasIncluded7702, + is_hardware_wallet: false, + location: 'Main View', + price_impact: 0, + provider: 'socket_across', + quoted_time_minutes: 1, + stx_enabled: stxEnabled, + swap_type: 'single_chain', + token_address_destination: + 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + token_address_source: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + token_security_type_destination: null, + token_symbol_destination: 'USDC', + token_symbol_source: 'USDC', + usd_amount_source: 100, + usd_quoted_gas: 0, + usd_quoted_return: 0, + }, + ], + [ + 'TransactionController:isAtomicBatchSupported', + { + address: '0xaccount1', + chainIds: ['0xa'], + }, + ], + ['AccountsController:getAccountByAddress', '0xaccount1'], + ['NetworkController:findNetworkClientIdByChainId', '0xa'], + ['TransactionController:getState'], + [ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Failed', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + chain_id_destination: 'eip155:10', + chain_id_source: 'eip155:10', + custom_slippage: false, + error_message: + 'Failed to add BatchSell trade to history: txMeta not found', + gas_included: false, + gas_included_7702: true, + is_hardware_wallet: false, + location: 'Main View', + price_impact: 0, + provider: 'socket_across', + quoted_time_minutes: 1, + stx_enabled: false, + swap_type: 'single_chain', + token_address_destination: + 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + token_address_source: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + token_security_type_destination: null, + token_symbol_destination: 'USDC', + token_symbol_source: 'USDC', + usd_amount_source: 100, + usd_quoted_gas: 0, + usd_quoted_return: 0, + }, + ], + ]); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + + // Verify that history item was not added + expect(controller.state.txHistory).toStrictEqual(expectedHistory); + }, + ); + }); + }); +}); 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 7e8ff496d9..9977f5d44a 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -3334,7 +3334,7 @@ describe('BridgeStatusController', () => { quote: { ...mockEvmQuoteResponse.quote, srcChainId: 59144 }, trade: { ...(mockEvmQuoteResponse.trade as TxData), - gasLimit: undefined, + gasLimit: undefined as never, }, }; @@ -3381,7 +3381,7 @@ describe('BridgeStatusController', () => { quote: { ...mockEvmQuoteResponse.quote, srcChainId: 8453 }, trade: { ...(mockEvmQuoteResponse.trade as TxData), - gasLimit: undefined, + gasLimit: undefined as never, }, }; @@ -5111,6 +5111,10 @@ describe('BridgeStatusController', () => { .fn() .mockResolvedValueOnce(MockStatusResponse.getPending()); + jest.spyOn(Date, 'now').mockReturnValueOnce(1779988919705); + jest.spyOn(Date, 'now').mockReturnValueOnce(1779988919706); + jest.spyOn(Date, 'now').mockReturnValue(1779988919707); + // Create base history item for actionId-keyed entries const baseHistoryItem = MockTxHistory.getPending().bridgeTxMetaId1; diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index c705b66c67..d945fd0aad 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -5,6 +5,7 @@ import type { QuoteResponse, Trade, FeatureId, + BatchSellTradesResponse, } from '@metamask/bridge-controller'; import { isNonEvmChainId, @@ -49,7 +50,11 @@ import type { BridgeStatusControllerMessenger } from './types'; import { BridgeClientId } from './types'; import { getAccountByAddress } from './utils/accounts'; import { getJwt } from './utils/authentication'; -import { stopPollingForQuotes, trackMetricsEvent } from './utils/bridge'; +import { + getBatchSellTrades, + stopPollingForQuotes, + trackMetricsEvent, +} from './utils/bridge'; import { fetchBridgeTxStatus, getStatusRequestWithSrcTxHash, @@ -82,6 +87,7 @@ import { checkIsDelegatedAccount, isCrossChainTx, updateTransactionsInBatch, + hasNestedSwapTransactions, } from './utils/transaction'; const metadata: StateMetadata = { @@ -109,6 +115,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'resetState', 'submitTx', 'submitIntent', + 'submitBatchSell', 'restartPollingForFailedAttempts', 'getBridgeHistoryItemByTxMetaId', ] as const; @@ -272,6 +279,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { if (!historyKey) { return; @@ -892,6 +906,9 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, + maybeQuoteResponses: + | (QuoteResponse & QuoteMetadata) + | (QuoteResponse & QuoteMetadata)[], isStxEnabled: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.MainView, abTests?: Record, activeAbTests?: { key: string; value: string }[], tokenSecurityTypeDestination?: string | null, + batchSellTrades?: BatchSellTradesResponse | null, ): Promise => { + /** + * If there are multiple quote responses, we assume that they all originate from the same src chain + * and the same account. In this case its safe to use the first quote response's properties for + * metrics and other pre-submission logic + */ + const quoteResponses = Array.isArray(maybeQuoteResponses) + ? maybeQuoteResponses + : [maybeQuoteResponses]; + const quoteResponse = quoteResponses[0]; + const { featureId, quote } = quoteResponse; const startTime = Date.now(); @@ -1078,6 +1109,7 @@ export class BridgeStatusController extends StaticIntervalPollingController = { messenger: this.messenger, - quoteResponse, + quoteResponses, + batchSellTrades, isStxEnabled, isBridgeTx, isDelegatedAccount, @@ -1192,6 +1225,39 @@ export class BridgeStatusController extends StaticIntervalPollingController | null)[]; + accountAddress: string; + location?: MetaMetricsSwapsEventSource; + abTests?: Record; + activeAbTests?: { key: string; value: string }[]; + isStxEnabled?: boolean; + quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived]; + tokenSecurityTypeDestination?: string | null; + }): Promise => { + /** + * Retrieve the batch sell trades from the BridgeController's state to ensure we submit + * the original response data from the bridge-api + */ + const batchSellTrades = getBatchSellTrades(this.messenger); + return await this.submitTx( + params.accountAddress, + params.quoteResponses.filter( + ( + quoteResponse, + ): quoteResponse is QuoteResponse & QuoteMetadata => + quoteResponse !== null, + ), + params.isStxEnabled ?? false, + params.quotesReceivedContext, + params.location, + params.abTests, + params.activeAbTests, + params.tokenSecurityTypeDestination, + batchSellTrades, + ); + }; + readonly #trackPollingStatusUpdatedEvent = ( historyKey: string, pollingStatus: PollingStatus, diff --git a/packages/bridge-status-controller/src/index.ts b/packages/bridge-status-controller/src/index.ts index c2eb5c432d..0b72236196 100644 --- a/packages/bridge-status-controller/src/index.ts +++ b/packages/bridge-status-controller/src/index.ts @@ -39,3 +39,8 @@ export type { export { BridgeId, BridgeStatusAction } from './types'; export { BridgeStatusController } from './bridge-status-controller'; + +export { + getBatchSellHistoryItemsForTxHash, + isBatchSellHistoryItem, +} from './utils/history'; diff --git a/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts new file mode 100644 index 0000000000..364f7b4ca2 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts @@ -0,0 +1,150 @@ +import { BatchSellTradesResponse, TxData } from '@metamask/bridge-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; + +import { QuoteAndTxMetadata } from '../types'; +import { + findAllTransactionsInBatch, + getAddTransactionBatchParams, + hasNestedSwapTransactions, + is7702Tx, + isTradeTx, + shouldDisable7702, + toQuoteAndTxMetadataBatch, +} from '../utils/transaction'; +import { SubmitStep } from './types'; +import type { SubmitStrategyParams, SubmitStepResult } from './types'; + +const getHistoryKeyForQuote = ({ + quoteResponse: { quoteId, quote }, +}: QuoteAndTxMetadata): string => quoteId ?? quote.requestId; + +/** + * Submits batch-sell transactions to the TransactionController + * + * @param args - The parameters for the transaction + * @yields The approvalMeta and tradeMeta for the first batch sell transaction + */ +export async function* submitBatchSellHandler( + args: SubmitStrategyParams, +): AsyncGenerator { + const { + requireApproval, + quoteResponses, + messenger, + addTransactionBatchFn, + isDelegatedAccount, + batchSellTrades, + } = args; + + const tradeData = toQuoteAndTxMetadataBatch({ + quoteResponses, + batchSellTrades, + }); + + const { gasIncluded7702, gasIncluded, gasSponsored } = batchSellTrades; + + const gasFeeToken = tradeData.find( + ({ type }) => type === TransactionType.tokenMethodTransfer, + )?.tx.to; + + const transactionParams = await getAddTransactionBatchParams({ + messenger, + tradeData, + requireApproval, + isDelegatedAccount, + // Tx success/failure is independent of other txs in the batch + atomic: false, + disable7702: shouldDisable7702( + gasIncluded7702, + gasIncluded, + isDelegatedAccount, + ), + isGasFeeSponsored: gasSponsored, + isGasFeeIncluded: Boolean(gasIncluded7702), + skipInitialGasEstimate: false, + excludeNativeTokenForFee: Boolean(gasFeeToken), + }); + + // Submit the batch to the TransactionController + const { batchId } = await addTransactionBatchFn(transactionParams); + + // Find all batch transaction metas and add them to history + const allTradesInBatch = findAllTransactionsInBatch({ + messenger, + batchId, + tradeData, + }).filter( + (metadata): metadata is QuoteAndTxMetadata & { txMeta: TransactionMeta } => + isTradeTx(metadata.type) && metadata.txMeta !== undefined, + ); + + // This is either the delegation tx or the first STX swap in the batch + const firstTradeWithMetadata = allTradesInBatch.find( + ({ txMeta }) => + txMeta?.type && + (isTradeTx(txMeta.type) || hasNestedSwapTransactions(txMeta)), + ); + const firstTradeMeta = firstTradeWithMetadata?.txMeta; + if (!firstTradeMeta) { + throw new Error( + 'Failed to add BatchSell trade to history: txMeta not found', + ); + } + + // Nested/7702 batch + if (is7702Tx(firstTradeMeta) || hasNestedSwapTransactions(firstTradeMeta)) { + const quoteIds = Array.from( + new Set(allTradesInBatch.map(getHistoryKeyForQuote)), + ); + + // Create 1 history item for the parent tx, keyed by the txMeta.id + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: firstTradeMeta.id, + quoteResponse: firstTradeWithMetadata.quoteResponse, + batchSellData: batchSellTrades, + quoteIds, + bridgeTxMeta: firstTradeMeta, + }, + }; + // Then create a new history item for each nested trade, keyed by quoteId/requestId + for (const tradeWithMetadata of allTradesInBatch) { + const { quoteResponse } = tradeWithMetadata; + + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: getHistoryKeyForQuote(tradeWithMetadata), + quoteResponse, + batchSellData: batchSellTrades, + }, + }; + } + } else { + // Each trade has its own txMeta if not submitted via 7702 + // Create a new history item for each one, keyed by txMeta.id + // Note that the approvalTxId is not tracked in history + for (const { txMeta, quoteResponse } of allTradesInBatch) { + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: txMeta.id, + quoteResponse, + batchSellData: batchSellTrades, + bridgeTxMeta: txMeta, + }, + }; + } + } + + yield { + type: SubmitStep.SetTradeMeta, + payload: { + tradeMeta: firstTradeMeta, + }, + }; +} diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts index fe9685984d..88f7357e69 100644 --- a/packages/bridge-status-controller/src/strategy/batch-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -5,6 +5,7 @@ import { getAddTransactionBatchParams, isApprovalTx, isTradeTx, + shouldDisable7702, toQuoteAndTxMetadata, } from '../utils/transaction'; import { SubmitStep } from './types'; @@ -21,7 +22,7 @@ export async function* submitBatchHandler( ): AsyncGenerator { const { requireApproval, - quoteResponse, + quoteResponses: [quoteResponse], messenger, isBridgeTx, addTransactionBatchFn, @@ -39,14 +40,11 @@ export async function* submitBatchHandler( isDelegatedAccount, messenger, atomic: true, - disable7702: - // Enable 7702 batching when the quote includes gasless 7702 support, - quoteResponse.quote.gasIncluded7702 - ? false - : // or when the account is already delegated (to avoid the in-flight transaction limit for delegated accounts) - !isDelegatedAccount || - // For gasless transactions with STX/sendBundle we keep disabling 7702. - quoteResponse.quote.gasIncluded, + disable7702: shouldDisable7702( + quoteResponse.quote.gasIncluded7702, + quoteResponse.quote.gasIncluded, + isDelegatedAccount, + ), isGasFeeSponsored: Boolean(quoteResponse.quote.gasSponsored), isGasFeeIncluded: Boolean(quoteResponse.quote.gasIncluded7702), }); diff --git a/packages/bridge-status-controller/src/strategy/evm-strategy.ts b/packages/bridge-status-controller/src/strategy/evm-strategy.ts index 6409f5a4a3..a3f3325370 100644 --- a/packages/bridge-status-controller/src/strategy/evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/evm-strategy.ts @@ -95,7 +95,10 @@ export const handleSingleTx = async ({ * @returns The approvalTxId of the approval transaction */ const approve = async (args: SubmitStrategyParams) => { - const { quoteResponse, isBridgeTx } = args; + const { + quoteResponses: [quoteResponse], + isBridgeTx, + } = args; const { approval, resetApproval } = quoteResponse; if (!approval || !isEvmTxData(approval)) { return undefined; @@ -125,7 +128,7 @@ const approve = async (args: SubmitStrategyParams) => { export const handleEvmApprovals = async (args: SubmitStrategyParams) => await args.traceFn( - getApprovalTraceParams(args.quoteResponse, args.isStxEnabled), + getApprovalTraceParams(args.quoteResponses[0], args.isStxEnabled), async () => await approve(args), ); @@ -138,7 +141,11 @@ export const handleEvmApprovals = async (args: SubmitStrategyParams) => export async function* submitEvmHandler( args: SubmitStrategyParams, ): AsyncGenerator { - const { quoteResponse, requireApproval, isBridgeTx } = args; + const { + quoteResponses: [quoteResponse], + requireApproval, + isBridgeTx, + } = args; // Submit resetApproval and approval transactions if present const approvalTxId = await handleEvmApprovals(args); diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts index 93083e4d8a..6d04074cac 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, @@ -11,6 +12,7 @@ import { TxData, } from '@metamask/bridge-controller'; +import { submitBatchSellHandler } from './batch-sell-strategy'; import { submitBatchHandler } from './batch-strategy'; import { submitEvmHandler as defaultSubmitHandler } from './evm-strategy'; import { submitIntentHandler } from './intent-strategy'; @@ -22,13 +24,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: @@ -40,6 +45,11 @@ const validateParams = < } }; +const validateBatchSellParams = ( + params: SubmitStrategyParams, +): params is SubmitStrategyParams => + Boolean(params.batchSellTrades) && params.quoteResponses.length > 1; + /** * Selects the appropriate submit strategy based on the quote parameters then executes it * @@ -50,7 +60,11 @@ const validateParams = < const executeSubmitStrategy = ( params: SubmitStrategyParams, ): AsyncGenerator => { - const { quoteResponse, isStxEnabled, isDelegatedAccount } = params; + const { + quoteResponses: [quoteResponse], + isStxEnabled, + isDelegatedAccount, + } = params; // Non-EVM transactions if (isNonEvmChainId(quoteResponse.quote.srcChainId)) { @@ -74,6 +88,11 @@ const executeSubmitStrategy = ( return submitIntentHandler(params); } + // Batch sell transactions + if (validateBatchSellParams(params)) { + return submitBatchSellHandler(params); + } + // Batched transactions const shouldBatchTxs = isStxEnabled || quoteResponse.quote.gasIncluded7702 || isDelegatedAccount; diff --git a/packages/bridge-status-controller/src/strategy/intent-strategy.ts b/packages/bridge-status-controller/src/strategy/intent-strategy.ts index d9c76b2a81..ef35263cfd 100644 --- a/packages/bridge-status-controller/src/strategy/intent-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/intent-strategy.ts @@ -34,7 +34,12 @@ const handleSyntheticTx = async ( orderUid: string, args: SubmitStrategyParams, ) => { - const { quoteResponse, messenger, isBridgeTx, selectedAccount } = args; + const { + quoteResponses: [quoteResponse], + messenger, + isBridgeTx, + selectedAccount, + } = args; const { quote: { srcChainId }, } = quoteResponse; @@ -95,7 +100,7 @@ const handleSyntheticTx = async ( */ const handleSubmitIntent = async (args: SubmitStrategyParams) => { const { - quoteResponse, + quoteResponses: [quoteResponse], messenger, selectedAccount, clientId, @@ -188,7 +193,7 @@ export async function* submitIntentHandler( approvalTxId, // Keep original txId for TransactionController updates originalTransactionId: syntheticTxMeta?.id, - quoteResponse: args.quoteResponse, + quoteResponse: args.quoteResponses[0], }, }; diff --git a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts index b6e430d7fc..1c57c21bf6 100644 --- a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts +++ b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts @@ -23,7 +23,10 @@ const handleTronApproval = async ( TronTradeData | BitcoinTradeData | string | TxData >, ) => { - const { quoteResponse, traceFn } = args; + const { + quoteResponses: [quoteResponse], + traceFn, + } = args; const approvalTxId = await traceFn( getApprovalTraceParams(quoteResponse, false), @@ -65,7 +68,10 @@ export async function* submitNonEvmHandler( BitcoinTradeData | TronTradeData | string | TxData >, ): AsyncGenerator { - const { quoteResponse, isBridgeTx } = args; + const { + quoteResponses: [quoteResponse], + isBridgeTx, + } = args; const approvalTxId = await handleTronApproval(args); diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index dcb54950e9..2458ac41f3 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -1,5 +1,6 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { + BatchSellTradesResponse, BridgeClientId, QuoteMetadata, QuoteResponse, @@ -40,6 +41,8 @@ export type SubmitStepResult = > & { historyKey: string; quoteResponse: QuoteResponse & QuoteMetadata; + batchSellData?: BatchSellTradesResponse; + quoteIds?: string[]; }; } | { @@ -84,13 +87,20 @@ export type SubmitStepResult = /** * The parameters for the submission flow */ -export type SubmitStrategyParams = { +export type SubmitStrategyParams< + TradeType extends Trade = TxData, + BatchSellTradesResponseType extends + | BatchSellTradesResponse + | undefined + | null = BatchSellTradesResponse | undefined | null, +> = { + batchSellTrades: BatchSellTradesResponseType; addTransactionBatchFn: TransactionController['addTransactionBatch']; isBridgeTx: boolean; isDelegatedAccount: boolean; isStxEnabled: boolean; messenger: BridgeStatusControllerMessenger; - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponses: (QuoteResponse & QuoteMetadata)[]; requireApproval: boolean; selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string]; traceFn: TraceCallback; diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index e0ba2cd75a..16763b1638 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -15,6 +15,8 @@ import type { TxFeeGasLimits, BridgeControllerTrackUnifiedSwapBridgeEventAction, BridgeControllerStopPollingForQuotesAction, + BatchSellTradesResponse, + BridgeControllerGetStateAction, } from '@metamask/bridge-controller'; import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; @@ -139,6 +141,16 @@ export type BridgeHistoryItem = { */ originalTransactionId?: string; // Keep original transaction ID for intent transactions batchId?: string; + /** + * This is defined when the history item is for a batch sell transaction + */ + batchSellData?: BatchSellTradesResponse; + /** + * This is defined when the history item corresponds to the 7702 batch's delegation tx. + * It contains the list of quoteIds for the BatchSell quotes that are part of the 7702 batch. + * Each quote can be retrieved from txHistory as `txHistory[quoteId]`. + */ + quoteIds?: string[]; quote: Quote; status: StatusResponse; startTime: number; // timestamp in ms @@ -253,6 +265,8 @@ export type QuoteMetadataSerialized = { export type StartPollingForBridgeTxStatusArgs = { bridgeTxMeta?: Pick; actionId?: string; + batchSellData?: BridgeHistoryItem['batchSellData']; + quoteIds?: BridgeHistoryItem['quoteIds']; /** * @deprecated the txMeta or orderUid should be used instead */ @@ -284,7 +298,7 @@ export type StartPollingForBridgeTxStatusArgsSerialized = Omit< StartPollingForBridgeTxStatusArgs, 'quoteResponse' > & { - quoteResponse: QuoteResponse & Partial; + quoteResponse: QuoteResponse & QuoteMetadata; }; export type SourceChainTxMetaId = string; @@ -337,6 +351,7 @@ type AllowedActions = | TransactionControllerIsAtomicBatchSupportedAction | BridgeControllerTrackUnifiedSwapBridgeEventAction | BridgeControllerStopPollingForQuotesAction + | BridgeControllerGetStateAction | AccountsControllerGetAccountByAddressAction | AuthenticationControllerGetBearerTokenAction | KeyringControllerSignTypedMessageAction; diff --git a/packages/bridge-status-controller/src/utils/bridge.ts b/packages/bridge-status-controller/src/utils/bridge.ts index b5a94ff3a7..5a541bed20 100644 --- a/packages/bridge-status-controller/src/utils/bridge.ts +++ b/packages/bridge-status-controller/src/utils/bridge.ts @@ -2,8 +2,9 @@ import { AbortReason, FeatureId, UnifiedSwapBridgeEventName, + BatchSellTradesResponse, + RequiredEventContextFromClient, } from '@metamask/bridge-controller'; -import type { RequiredEventContextFromClient } from '@metamask/bridge-controller'; import { BridgeStatusControllerMessenger } from '../types'; @@ -21,6 +22,12 @@ export const stopPollingForQuotes = ( ); }; +export const getBatchSellTrades = ( + messenger: BridgeStatusControllerMessenger, +): BatchSellTradesResponse | null => { + return messenger.call('BridgeController:getState').batchSellTrades; +}; + export const trackMetricsEvent = ({ messenger, eventName, diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index af227514fa..0ccfb09ce6 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -51,6 +51,10 @@ export const rekeyHistoryItemInState = ( return true; }; +export const isBatchSellHistoryItem = ( + historyItem: BridgeHistoryItem, +): boolean => Boolean(historyItem?.batchSellData); + /** * Returns the history entry that matches the txMeta by id, actionId, batchId, or txHash * @@ -78,7 +82,12 @@ export const getMatchingHistoryEntryForTxMeta = ( key === txMeta.actionId || txMetaId === txMeta.id || (actionId ? actionId === txMeta.actionId : false) || - (batchId ? batchId === txMeta.batchId : false) || + // When the batch is not atomic (BatchSell), ignore batchId matching to prevent txs + // in the batch from getting marked complete/failed too early if one fails + // Multiple BatchSell STX trades may have the same batchId + (Boolean(batchId) && + !isBatchSellHistoryItem(value) && + batchId === txMeta.batchId) || (txHash ? txHash.toLowerCase() === txMeta.hash?.toLowerCase() : false) ); }); @@ -102,6 +111,54 @@ export const getMatchingHistoryEntryForApprovalTxMeta = ( ); }; +/** + * Returns the BatchSell history items in the same batch as the provided tx hash. + * + * @param txHistory - The bridge status controller's history to search for matching history items + * @param txHashOrId - the hash or txMeta.id of a single trade in a BatchSell + * @returns The matching history items for the tx hash and a boolean indicating if it's a 7702 batch. + * @example + * getBatchSellHistoryItemsForTxHash(txHistory, id) + * If id is the hash or txMetaId of a BatchSell trade, it will return the history items for + * the trade and all other trades in the same batch. + */ +export const getBatchSellHistoryItemsForTxHash = ( + txHistory: BridgeStatusControllerState['txHistory'], + txHashOrId?: string, +): { historyItems: BridgeHistoryItem[]; is7702Batch: boolean } => { + const historyItems = Object.values(txHistory); + + if (!txHashOrId) { + return { + historyItems: [], + is7702Batch: false, + }; + } + + /** + * Either a delegation tx or a single STX BatchSell trade + */ + const parentHistoryItem = historyItems.find( + ({ status, txMetaId }) => + status.srcChain.txHash?.toLowerCase() === txHashOrId.toLowerCase() || + txMetaId === txHashOrId, + ); + + // Match by batchId or by quoteId + const matchingHistoryItems = + parentHistoryItem?.quoteIds?.map((quoteId) => txHistory[quoteId]) ?? + historyItems.filter( + ({ batchId }) => batchId === parentHistoryItem?.batchId, + ); + + return { + historyItems: matchingHistoryItems.filter((item) => item !== undefined), + is7702Batch: + Boolean(parentHistoryItem) && + Boolean(parentHistoryItem?.quoteIds?.length), + }; +}; + /** * Determines the key to use for storing a bridge history item. * Uses actionId for pre-submission tracking, or bridgeTxMetaId for post-submission. @@ -146,11 +203,13 @@ export const getInitialHistoryItem = ( originalTransactionId, actionId, tokenSecurityTypeDestination, + batchSellData, + quoteIds, } = args; // Write all non-status fields to state so we can reference the quote in Activity list without the Bridge API // We know it's in progress but not the exact status yet - const txHistoryItem = { + const txHistoryItem: BridgeHistoryItem = { txMetaId: bridgeTxMeta?.id, actionId, originalTransactionId: originalTransactionId ?? bridgeTxMeta?.id, // Keep original for intent transactions @@ -196,6 +255,13 @@ export const getInitialHistoryItem = ( }), }; + if (batchSellData) { + txHistoryItem.batchSellData = batchSellData; + } + if (quoteIds) { + txHistoryItem.quoteIds = quoteIds; + } + return txHistoryItem; }; diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 3f8c6fd6f7..772634f9e3 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -26,6 +26,7 @@ import type { TradeData, RequestMetadata, PollingStatus, + BatchSellTradesResponse, } from '@metamask/bridge-controller'; import { TransactionStatus, @@ -157,12 +158,17 @@ export const getRequestParamFromHistory = ( }; export const getTradeDataFromQuote = ( - quoteResponse: QuoteResponse & Partial, + quoteResponse: QuoteResponse & QuoteMetadata, + batchSellTrades?: BatchSellTradesResponse | null, ): TradeData => { return { usd_quoted_gas: Number(quoteResponse.gasFee?.effective?.usd ?? 0), - gas_included: quoteResponse.quote.gasIncluded ?? false, - gas_included_7702: quoteResponse.quote.gasIncluded7702 ?? false, + gas_included: + quoteResponse.quote.gasIncluded ?? batchSellTrades?.gasIncluded ?? false, + gas_included_7702: + quoteResponse.quote.gasIncluded7702 ?? + batchSellTrades?.gasIncluded7702 ?? + false, provider: formatProviderLabel(quoteResponse.quote), quoted_time_minutes: Number( quoteResponse.estimatedProcessingTimeInSeconds / 60, @@ -188,21 +194,23 @@ export const getPriceImpactFromQuote = ( * @param abTests - Legacy A/B test context for `ab_tests` (backward compatibility) * @param activeAbTests - New A/B test context for `active_ab_tests` (migration target) * @param tokenSecurityTypeDestination - 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. + * @param batchSellTrades - The batch sell trades response * @returns The properties for the pre-confirmation event */ export const getPreConfirmationPropertiesFromQuote = ( - quoteResponse: QuoteResponse & Partial, + quoteResponse: QuoteResponse & QuoteMetadata, isStxEnabled: boolean, accountHardwareType: AccountHardwareType, location?: MetaMetricsSwapsEventSource, abTests?: Record, activeAbTests?: { key: string; value: string }[], tokenSecurityTypeDestination?: string | null, + batchSellTrades?: BatchSellTradesResponse | null, ) => { const { quote } = quoteResponse; return { ...getPriceImpactFromQuote(quote), - ...getTradeDataFromQuote(quoteResponse), + ...getTradeDataFromQuote(quoteResponse, batchSellTrades), chain_id_source: formatChainIdToCaip(quote.srcChainId), token_symbol_source: quote.srcAsset.symbol, token_address_source: quote.srcAsset.assetId, diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index db7930ec33..1a00938cee 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -5,8 +5,10 @@ import { BRIDGE_PREFERRED_GAS_ESTIMATE, isEvmTxData, FeeType, + BatchSellTransactionType, } from '@metamask/bridge-controller'; import type { + BatchSellTradesResponse, QuoteMetadata, QuoteResponse, SimulatedGasFeeLimits, @@ -51,7 +53,7 @@ export const isCrossChainTx = (type: TransactionType) => * @param tx - The transaction meta * @returns Whether the transaction is a 7702 transaction */ -const is7702Tx = (tx: TransactionMeta) => { +export const is7702Tx = (tx: TransactionMeta) => { return ( (Array.isArray(tx.txParams.authorizationList) && tx.txParams.authorizationList.length > 0) || @@ -59,6 +61,33 @@ const is7702Tx = (tx: TransactionMeta) => { ); }; +export const shouldDisable7702 = ( + gasIncluded7702: boolean = false, + gasIncluded: boolean = false, + isDelegatedAccount: boolean = false, +) => { + // Enable 7702 batching when the quote includes gasless 7702 support + if (gasIncluded7702) { + return false; + } + // Enable batching when the account is already delegated (to avoid the in-flight transaction limit for delegated accounts) + if (isDelegatedAccount) { + return false; + } + // For gasless transactions with STX/sendBundle we keep disabling 7702 + if (gasIncluded) { + return true; + } + // Default value + return true; +}; + +export const hasNestedSwapTransactions = (txMeta: TransactionMeta) => { + return Boolean( + txMeta?.nestedTransactions?.some((tx) => tx.type === TransactionType.swap), + ); +}; + export const getGasFeeEstimates = async ( messenger: BridgeStatusControllerMessenger, args: Parameters[0], @@ -269,7 +298,7 @@ export const toQuoteAndTxMetadata = ({ }: { quoteResponse: QuoteResponse & QuoteMetadata; isBridgeTx: boolean; -}) => { +}): Omit[] => { const tradeData: QuoteAndTxMetadata[] = []; const approvalTxType = isBridgeTx @@ -306,6 +335,83 @@ export const toQuoteAndTxMetadata = ({ return tradeData; }; +/** + * Build the trade+quote metadata array for the batch sell transaction + * This ties together the quote, the tx params and the txMeta after submission + * + * @param options - The options for the batch sell transaction + * @param options.quoteResponses - The quote responses for the batch sell transaction + * @param options.batchSellTrades - The batch sell trades for the batch sell transaction + * @returns The trade+quote metadata array for the batch sell transaction + */ +export const toQuoteAndTxMetadataBatch = ({ + quoteResponses, + batchSellTrades, +}: { + quoteResponses: (QuoteResponse & QuoteMetadata)[]; + batchSellTrades: BatchSellTradesResponse; +}): Omit[] => { + const tradeData: QuoteAndTxMetadata[] = []; + + const { + transactions, + gasIncluded7702, + gasIncluded, + gasSponsored = false, + } = batchSellTrades; + + for (const transaction of transactions) { + const { type, maxFeePerGas, maxPriorityFeePerGas, ...tx } = transaction; + // Match the trade or approval tx data with the quote response + const matchingQuoteResponse = + quoteResponses.find( + ({ approval, trade }) => + trade?.data.toLowerCase() === tx.data.toLowerCase() || + approval?.data.toLowerCase() === tx.data.toLowerCase(), + ) ?? quoteResponses[0]; + + // Include gasIncluded and gasIncluded7702 from the gasless batch + const normalizedQuote = { + ...matchingQuoteResponse, + quote: { + ...matchingQuoteResponse.quote, + gasIncluded, + gasIncluded7702, + gasSponsored, + }, + }; + + const commonTradeData = { + tx, + quoteResponse: normalizedQuote, + txFee: { maxFeePerGas, maxPriorityFeePerGas }, + }; + + if (type === BatchSellTransactionType.TRADE) { + tradeData.push({ + ...commonTradeData, + type: TransactionType.swap, + assetsFiatValues: { + sending: + matchingQuoteResponse.sentAmount?.valueInCurrency?.toString(), + receiving: + matchingQuoteResponse.toTokenAmount?.valueInCurrency?.toString(), + }, + }); + } else { + tradeData.push({ + ...commonTradeData, + type: + type === BatchSellTransactionType.APPROVAL + ? TransactionType.swapApproval + : TransactionType.tokenMethodTransfer, + }); + } + } + + return tradeData; +}; + /** * Appends the gas fee estimates for a transaction and normalizes the trade data * diff --git a/packages/bridge-status-controller/test/mock-batch-sell-erc20-erc20.ts b/packages/bridge-status-controller/test/mock-batch-sell-erc20-erc20.ts new file mode 100644 index 0000000000..5879795485 --- /dev/null +++ b/packages/bridge-status-controller/test/mock-batch-sell-erc20-erc20.ts @@ -0,0 +1,302 @@ +import { + BatchSellTransactionType, + getNativeAssetForChainId, + MetaMetricsSwapsEventSource, + BatchSellTradesResponse, + Quote, + QuoteResponse, + StatusTypes, + TxData, +} from '@metamask/bridge-controller'; +import { toHex } from '@metamask/controller-utils'; +import { + TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; + +import { BridgeHistoryItem } from '../src'; + +export const mockBatchSellErc20Erc20: QuoteResponse[] = [ + { + quote: { + requestId: '90ae8e69-f03a-4cf6-bab7-ed4e3431eb37', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + srcTokenAmount: '14000000', + destChainId: 10, + destAsset: { + chainId: 10, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + destTokenAmount: '13984280', + minDestTokenAmount: '13700000', + feeData: { + metabridge: { + amount: '0', + asset: { + chainId: 10, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + }, + }, + bridgeId: 'socket', + bridges: ['across'], + steps: [], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x038d7ea4c68000', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000004a0c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000284792ebcb90000000000000000000000000000000000000000000000000000000000d59f80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000454000000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000d55a40000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f42c7402906f9888136205038026f20b3f6df2899044cab41d632bc7a6c35debd40516df85de6f194aeb05b72cb9ea4d5ce0f7c56c91a79536331112f1a846dc641c', + gasLimit: 287227, + }, + estimatedProcessingTimeInSeconds: 60, + }, + { + quote: { + requestId: '0b6caac9-456d-47e6-8982-1945ae81ae82', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff81', + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff81', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDT', + }, + srcTokenAmount: '14000000', + destChainId: 10, + destAsset: { + chainId: 10, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + destTokenAmount: '13800000', + minDestTokenAmount: '13530000', + feeData: { + metabridge: { + amount: '0', + asset: { + chainId: 10, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff81', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff81', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x038d7ea4c68000', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000002e4c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000018c0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4b7dfe9d00000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000030d400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000138bc5930d51a475e4669db259f69e61ca33803675e76540f062a76af8cbaef4672c9926e56d6a8c29a263de3ee8f734ad760461c448f82fdccdd8c2360fffba1b', + gasLimit: 343079, + }, + estimatedProcessingTimeInSeconds: 1560, + }, +]; + +export const mockBatchSellTradesErc20Erc20: BatchSellTradesResponse = { + transactions: mockBatchSellErc20Erc20.flatMap(({ trade, approval }) => + [ + { + ...(trade as TxData), + type: BatchSellTransactionType.TRADE, + maxFeePerGas: '0x154a94', + maxPriorityFeePerGas: '0xf4241', + } as const, + approval + ? ({ + ...(approval as TxData), + type: BatchSellTransactionType.APPROVAL, + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + } as const) + : undefined, + ].filter((tx) => tx !== undefined), + ), + fee: { + amount: '100', + asset: getNativeAssetForChainId(10), + }, +}; + +export const getTxMetasForBatch = ({ + batchId, + is7702, +}: { + batchId: `0x${string}`; + is7702: boolean; +}): TransactionMeta[] => { + let date = Date.now(); + + if (is7702) { + return [ + { + batchId, + id: date.toString(16), + hash: `0x${date.toString(16)}hash`, + time: date, + status: TransactionStatus.submitted, + type: TransactionType.batch, + chainId: '0xa', + batchTransactionsOptions: { + disable7702: !is7702, + }, + txParams: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: '0xa', + gasLimit: '0x5208', + authorizationList: [ + { + address: '0xupgradeAddress', + chainId: '0xa', + nonce: '0x1', + r: '0xr', + s: '0xs', + yParity: '0x1', + }, + ], + }, + delegationAddress: '0xdelegationAddress', + nestedTransactions: mockBatchSellTradesErc20Erc20.transactions.map( + ({ gasLimit, ...trade }) => ({ + ...trade, + chainId: '0xa', + gas: toHex(Number(gasLimit)), + type: + // eslint-disable-next-line no-nested-ternary + trade.type === BatchSellTransactionType.TRADE + ? TransactionType.swap + : trade.type === BatchSellTransactionType.APPROVAL + ? TransactionType.swapApproval + : TransactionType.tokenMethodTransfer, + }), + ), + networkClientId: 'test-network-client-id', + } as const, + ]; + } + + return mockBatchSellTradesErc20Erc20.transactions.map((trade) => { + date += 1; + return { + batchId, + id: date.toString(16), + hash: `0x${date.toString(16)}hash`, + time: date, + status: TransactionStatus.submitted, + type: + // eslint-disable-next-line no-nested-ternary + trade.type === BatchSellTransactionType.TRADE + ? TransactionType.swap + : trade.type === BatchSellTransactionType.APPROVAL + ? TransactionType.swapApproval + : TransactionType.tokenMethodTransfer, + chainId: '0xa', + batchTransactionsOptions: { + disable7702: !is7702, + }, + txParams: { + ...trade, + chainId: '0xa', + gasLimit: '0x5208', + }, + networkClientId: 'test-network-client-id', + } as const; + }); +}; + +export const getHistoryItem = ( + params: Partial, +): BridgeHistoryItem => { + const { isStxEnabled, batchSellData, txMetaId, quote, quoteIds } = params; + + return { + account: '0xaccount1', + actionId: undefined, + batchId: '0xBatchId1', + featureId: undefined, + hasApprovalTx: true, + isStxEnabled, + initialDestAssetBalance: undefined, + location: MetaMetricsSwapsEventSource.MainView, + originalTransactionId: txMetaId, + slippagePercentage: 0, + startTime: 1779922719705, + targetContractAddress: undefined, + txMetaId, + approvalTxId: undefined, + estimatedProcessingTimeInSeconds: 60, + pricingData: { + amountSent: '0', + amountSentInUsd: '100', + quotedGasAmount: undefined, + quotedGasInUsd: undefined, + quotedReturnInUsd: '101', + }, + status: { + srcChain: { + chainId: 10, + txHash: isStxEnabled ? undefined : `0x${txMetaId}hash`, + }, + status: StatusTypes.PENDING, + }, + batchSellData, + quote: quote as Quote, + ...(quoteIds ? { quoteIds } : {}), + }; +}; diff --git a/packages/bridge-status-controller/tsconfig.json b/packages/bridge-status-controller/tsconfig.json index 556d2cb48c..a7273156ac 100644 --- a/packages/bridge-status-controller/tsconfig.json +++ b/packages/bridge-status-controller/tsconfig.json @@ -16,5 +16,5 @@ { "path": "../profile-sync-controller" }, { "path": "../gas-fee-controller" } ], - "include": ["../../types", "./src"] + "include": ["../../types", "./src", "./test"] }