From c01f799a92c25dc27e4fafe235824357d1e0a6d9 Mon Sep 17 00:00:00 2001 From: micaelae Date: Wed, 13 May 2026 17:23:43 -0700 Subject: [PATCH 01/14] 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 | 7 + .../bridge-controller/src/selectors.test.ts | 256 ++++- packages/bridge-controller/src/selectors.ts | 56 +- packages/bridge-controller/src/types.ts | 34 +- .../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 | 154 +-- 18 files changed, 1504 insertions(+), 330 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 5af57fabf3..5ef53d9df0 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -40,6 +40,9 @@ export type { BridgeAsset, GenericQuoteRequest, Protocol, + BatchSellTradesResponse, + GaslessProperties, + SimulatedGasFeeLimits, TokenAmountValues, Step, RefuelData, @@ -57,6 +60,7 @@ export type { BridgeControllerEvents, BridgeControllerMessenger, FeatureFlagsPlatformConfig, + TxFeeGasLimits, } from './types'; export type { @@ -67,6 +71,7 @@ export type { BridgeControllerResetStateAction, BridgeControllerSetChainIntervalLengthAction, BridgeControllerTrackUnifiedSwapBridgeEventAction, + BridgeControllerFetchBatchSellTradesAction, } from './bridge-controller-method-action-types'; export { AbortReason } from './utils/metrics/constants'; @@ -94,6 +99,7 @@ export { TokenFeatureType, validateQuoteStreamComplete, QuoteStreamCompleteReason, + BatchSimulationTransactionType, } from './utils/validators'; export { @@ -175,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 5f030bd45e..de765f19a6 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -47,6 +47,10 @@ import type { QuoteStreamCompleteSchema, TronTradeDataSchema, TxDataSchema, + BatchSellTradesResponseSchema, + GaslessPropertiesSchema, + SimulatedGasFeeLimitsSchema, + TxFeeGasLimitsSchema, } from './utils/validators'; export type FetchFunction = ( @@ -310,6 +314,22 @@ export type QuoteResponse< quoteRequestIndex?: number; }; +export type BatchSellTradesRequest = { + quotes: QuoteResponse[]; +}; + +/** + * This is the bridge-api response for the obtainGaslessBatch method + */ +export type BatchSellTradesResponse = Infer< + typeof BatchSellTradesResponseSchema +>; + +export type SimulatedGasFeeLimits = Infer; +export type TxFeeGasLimits = Infer; + +export type GaslessProperties = Infer; + export enum ChainId { ETH = 1, OPTIMISM = 10, @@ -336,9 +356,9 @@ export type TokenFeature = Infer; export type QuoteStreamCompleteData = Infer; export enum RequestStatus { - LOADING, - FETCHED, - ERROR, + LOADING = 0, + FETCHED = 1, + ERROR = 2, } /** @@ -417,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 12cd1780b3..41f46bf7be 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -43,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(); @@ -369,65 +372,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 TxFeeGasLimitsSchema = type({ + maxFeePerGas: NumberStringSchema, + maxPriorityFeePerGas: NumberStringSchema, +}); + +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, TxFeeGasLimitsSchema]), + ), + }), + 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, @@ -526,3 +534,35 @@ export const validateQuoteStreamComplete = ( assert(data, QuoteStreamCompleteSchema); return true; }; + +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, + type({ type: enums(Object.values(BatchSimulationTransactionType)) }), + ]), + ), + fee: type({ + asset: BridgeAssetSchema, + amount: NumberStringSchema, + }), +}); + +export const validateBatchSellTradesResponse = ( + data: unknown, +): data is Infer => { + assert(data, BatchSellTradesResponseSchema); + return true; +}; From c2599ee89c59f2eb916cf83278175036da35722d Mon Sep 17 00:00:00 2001 From: micaelae Date: Wed, 13 May 2026 18:04:04 -0700 Subject: [PATCH 02/14] fix: lint and changelog --- packages/bridge-controller/CHANGELOG.md | 11 +++++++++++ .../src/bridge-controller.sse.batch.test.ts | 2 +- packages/bridge-controller/src/selectors.ts | 7 ++----- packages/bridge-controller/src/utils/fetch.test.ts | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index cdc0f45df7..53e45adc77 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Implement transaction batch and fee fetching for BatchSell quotes ([#8805](https://github.com/MetaMask/core/pull/8805)) + - add new states `batchSellTrades` and `batchSellTradesLoadingStatus` to contain transaction data and its fetch status + - support transaction batch data fetching with the new `fetchBatchSellTrades` handler. Clients will need to call this whenever the recommended quotes update + - implement `selectBatchSellTrades` selector which returns the ordered list of transactions to submit as a batch, including any transfer transactions required to cover gas costs. This also returns the `totalNetworkFee` provided by the `obtainGaslessBatch` endpoint and its converted values + ### Changed - Bump `@metamask/assets-controller` from `^7.1.1` to `^7.1.2` ([#8783](https://github.com/MetaMask/core/pull/8783)) @@ -14,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) - Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) +### Removed + +- **BREAKING**: Remove `totalNetworkFee` from the `selectBatchSellQuotes`'s results. Clients should use `selectBatchSellTrades` instead ([#8805](https://github.com/MetaMask/core/pull/8805)) + ## [72.0.4] ### Changed 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 e6bbb46b72..688e0015d5 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts @@ -432,7 +432,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () l1GasFeesInHexWei: '0x2', resetApproval: undefined, quoteRequestIndex: 1, - } as never), + }) as never, ), ), quotesRefreshCount: 1, diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 900538fe05..a628e2b8e7 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -660,14 +660,11 @@ const selectBatchSellFees = createBridgeSelector( * 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. + * @returns The ordered list of transactions to submit as a batch, and the total transaction fee. * * @example * ```ts - * const quotes = useSelector(state => selectBatchSellTrades(state.metamask)); + * const { batchSellTrades, totalNetworkFee, isLoading } = useSelector(state => selectBatchSellTrades(state.metamask)); * ``` */ export const selectBatchSellTrades = createBridgeSelector( diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 7f62794c07..f84cdadcea 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -4,6 +4,7 @@ import type { CaipAssetType } from '@metamask/utils'; import mockBridgeQuotesErc20Erc20 from '../../tests/mock-quotes-erc20-erc20.json'; import mockBridgeQuotesNativeErc20 from '../../tests/mock-quotes-native-erc20.json'; import { BridgeClientId, BRIDGE_PROD_API_BASE_URL } from '../constants/bridge'; +import { QuoteResponse } from '../types'; import { fetchBridgeQuotes, fetchBridgeTokens, @@ -11,7 +12,6 @@ import { fetchBatchSellTrades, } from './fetch'; import { BatchSimulationTransactionType, FeatureId } from './validators'; -import { QuoteResponse } from '../types'; const mockFetchFn = jest.fn(); From bb9931b4d43ac41846067b1460e7fa100958cac6 Mon Sep 17 00:00:00 2001 From: micaelae Date: Wed, 13 May 2026 18:13:13 -0700 Subject: [PATCH 03/14] fix: lint --- packages/bridge-controller/src/selectors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index a628e2b8e7..204a123673 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -528,8 +528,8 @@ export const selectIsQuoteExpired = createBridgeSelector( (isQuoteGoingToRefresh, quotesLastFetched, refreshRate, currentTimeInMs) => Boolean( !isQuoteGoingToRefresh && - quotesLastFetched && - currentTimeInMs - quotesLastFetched > refreshRate, + quotesLastFetched && + currentTimeInMs - quotesLastFetched > refreshRate, ), ); From 8086e318e475b136da85bde960456fab5f9a28df Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 15 May 2026 10:49:50 -0700 Subject: [PATCH 04/14] chore: test + rename --- packages/bridge-controller/CHANGELOG.md | 2 +- .../bridge-controller.sse.batch.test.ts.snap | 2 +- .../bridge-controller-method-action-types.ts | 8 +- .../src/bridge-controller.sse.batch.test.ts | 56 ++++----- .../src/bridge-controller.ts | 19 ++-- packages/bridge-controller/src/index.ts | 2 +- packages/bridge-controller/src/types.ts | 2 +- .../bridge-controller/src/utils/fetch.test.ts | 106 +++++++++++------- packages/bridge-controller/src/utils/fetch.ts | 46 ++++++-- packages/bridge-controller/src/utils/quote.ts | 1 - .../bridge-controller/src/utils/validators.ts | 1 + 11 files changed, 150 insertions(+), 95 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 53e45adc77..71f5a7bb24 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implement transaction batch and fee fetching for BatchSell quotes ([#8805](https://github.com/MetaMask/core/pull/8805)) - add new states `batchSellTrades` and `batchSellTradesLoadingStatus` to contain transaction data and its fetch status - - support transaction batch data fetching with the new `fetchBatchSellTrades` handler. Clients will need to call this whenever the recommended quotes update + - support transaction batch data fetching with the new `updateBatchSellTrades` handler. Clients will need to call this whenever the recommended quotes update - implement `selectBatchSellTrades` selector which returns the ordered list of transactions to submit as a batch, including any transfer transactions required to cover gas costs. This also returns the `totalNetworkFee` provided by the `obtainGaslessBatch` endpoint and its converted values ### Changed 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 e116784742..8c242116c1 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 @@ -9,7 +9,7 @@ exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes s }, }, "batchSellTrades": null, - "batchSellTradesLoadingStatus": null, + "batchSellTradesLoadingStatus": 0, "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 553707f0bf..12a7527abd 100644 --- a/packages/bridge-controller/src/bridge-controller-method-action-types.ts +++ b/packages/bridge-controller/src/bridge-controller-method-action-types.ts @@ -40,9 +40,9 @@ export type BridgeControllerTrackUnifiedSwapBridgeEventAction = { handler: BridgeController['trackUnifiedSwapBridgeEvent']; }; -export type BridgeControllerFetchBatchSellTradesAction = { - type: `BridgeController:fetchBatchSellTrades`; - handler: BridgeController['fetchBatchSellTrades']; +export type BridgeControllerUpdateBatchSellTradesAction = { + type: `BridgeController:updateBatchSellTrades`; + handler: BridgeController['updateBatchSellTrades']; }; /** @@ -56,4 +56,4 @@ export type BridgeControllerMethodActions = | BridgeControllerResetStateAction | BridgeControllerSetChainIntervalLengthAction | BridgeControllerTrackUnifiedSwapBridgeEventAction - | BridgeControllerFetchBatchSellTradesAction; + | BridgeControllerUpdateBatchSellTradesAction; 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 688e0015d5..ea2bd5dbc6 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts @@ -439,6 +439,8 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () quotesLoadingStatus: 1, quotesLastFetched: t1, assetExchangeRates, + batchSellTrades: null, + batchSellTradesLoadingStatus: RequestStatus.LOADING, }); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(consoleLogSpy).toHaveBeenCalledTimes(0); @@ -521,16 +523,17 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () ); // Initial fetch - await rootMessenger.call('BridgeController:fetchBatchSellTrades', []); + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [], + ); await jest.advanceTimersByTimeAsync(1000); await flushPromises(); expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); expect(abortControllerSpy).toHaveBeenCalledTimes(0); - expect(fetchBatchSellTradesSpy.mock.calls[0][0].quotes).toStrictEqual( - [], - ); + expect(fetchBatchSellTradesSpy.mock.calls[0][0]).toStrictEqual([]); expect(startPollingSpy).not.toHaveBeenCalled(); expect(bridgeController.state.batchSellTrades).toStrictEqual( mockBatchSellTrades, @@ -631,9 +634,12 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () ); // Call twice in a row - await rootMessenger.call('BridgeController:fetchBatchSellTrades', []); await rootMessenger.call( - 'BridgeController:fetchBatchSellTrades', + 'BridgeController:updateBatchSellTrades', + [], + ); + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], ); @@ -642,9 +648,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); expect(abortControllerSpy).toHaveBeenCalledTimes(1); - expect(fetchBatchSellTradesSpy.mock.calls[0][0].quotes).toStrictEqual( - [], - ); + expect(fetchBatchSellTradesSpy.mock.calls[0][0]).toStrictEqual([]); expect(startPollingSpy).not.toHaveBeenCalled(); expect(bridgeController.state.batchSellTrades).toStrictEqual( mockBatchSellTrades2, @@ -665,9 +669,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(2); expect(fetchBatchSellTradesSpy.mock.calls[0]).toStrictEqual([ - { - quotes: [], - }, + [], expect.any(AbortSignal), 'extension', 'AUTH_TOKEN', @@ -676,9 +678,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () '13.8.0', ]); expect(fetchBatchSellTradesSpy.mock.calls[1]).toStrictEqual([ - { - quotes: mockBridgeQuotesErc20Erc20, - }, + mockBridgeQuotesErc20Erc20, expect.any(AbortSignal), 'extension', 'AUTH_TOKEN', @@ -745,7 +745,10 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () ); // Reset after starting fetch - await rootMessenger.call('BridgeController:fetchBatchSellTrades', []); + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [], + ); rootMessenger.call('BridgeController:resetState'); await jest.advanceTimersByTimeAsync(1000); @@ -753,9 +756,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); expect(abortControllerSpy).toHaveBeenCalledTimes(2); - expect(fetchBatchSellTradesSpy.mock.calls[0][0].quotes).toStrictEqual( - [], - ); + expect(fetchBatchSellTradesSpy.mock.calls[0][0]).toStrictEqual([]); expect(startPollingSpy).not.toHaveBeenCalled(); expect(bridgeController.state.batchSellTrades).toBeNull(); expect( @@ -772,9 +773,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(1); expect(fetchBatchSellTradesSpy.mock.calls[0]).toStrictEqual([ - { - quotes: [], - }, + [], expect.any(AbortSignal), 'extension', 'AUTH_TOKEN', @@ -843,16 +842,17 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () ); // 1st fetch - await rootMessenger.call('BridgeController:fetchBatchSellTrades', []); + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [], + ); await jest.advanceTimersByTimeAsync(1000); await flushPromises(); expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); expect(abortControllerSpy).toHaveBeenCalledTimes(0); - expect(fetchBatchSellTradesSpy.mock.calls[0][0].quotes).toStrictEqual( - [], - ); + expect(fetchBatchSellTradesSpy.mock.calls[0][0]).toStrictEqual([]); expect(startPollingSpy).not.toHaveBeenCalled(); expect(bridgeController.state.batchSellTrades).toStrictEqual( mockBatchSellTrades, @@ -870,7 +870,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () // 2nd fetch await rootMessenger.call( - 'BridgeController:fetchBatchSellTrades', + 'BridgeController:updateBatchSellTrades', mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], ); @@ -879,7 +879,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); expect(abortControllerSpy).toHaveBeenCalledTimes(1); - expect(fetchBatchSellTradesSpy.mock.calls[1][0].quotes).toStrictEqual( + expect(fetchBatchSellTradesSpy.mock.calls[1][0]).toStrictEqual( mockBridgeQuotesErc20Erc20, ); expect(startPollingSpy).not.toHaveBeenCalled(); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index f230d862e6..9f1d41ff5e 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -210,7 +210,7 @@ type BridgePollingInput = { const MESSENGER_EXPOSED_METHODS = [ 'updateBridgeQuoteRequestParams', 'fetchQuotes', - 'fetchBatchSellTrades', + 'updateBatchSellTrades', 'stopPollingForQuotes', 'setLocation', 'resetState', @@ -451,19 +451,17 @@ export class BridgeController extends StaticIntervalPollingController => { + updateBatchSellTrades = async ( + quotes: (QuoteResponse | null)[], + ): Promise => { this.#batchSellTradesAbortController?.abort( AbortReason.GaslessTxBatchFetched, ); this.#batchSellTradesAbortController = new AbortController(); - this.update((state) => { - state.batchSellTradesLoadingStatus = RequestStatus.LOADING; - }); - try { const batchSellTradesResponse = await fetchBatchSellTrades( - { quotes }, + quotes, this.#batchSellTradesAbortController.signal, this.#clientId, await this.#getJwt(), @@ -785,6 +783,8 @@ export class BridgeController extends StaticIntervalPollingController { this.#abortController?.abort(AbortReason.NewQuoteRequest); + this.#batchSellTradesAbortController?.abort(AbortReason.NewQuoteRequest); + this.#abortController = new AbortController(); this.#fetchAssetExchangeRates(quoteRequests).catch((error) => @@ -809,6 +809,11 @@ export class BridgeController extends StaticIntervalPollingController 1) { + state.batchSellTradesLoadingStatus = RequestStatus.LOADING; + state.batchSellTrades = DEFAULT_BRIDGE_CONTROLLER_STATE.batchSellTrades; + } }); const jwt = await this.#getJwt(); diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 5ef53d9df0..34a0b93893 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -71,7 +71,7 @@ export type { BridgeControllerResetStateAction, BridgeControllerSetChainIntervalLengthAction, BridgeControllerTrackUnifiedSwapBridgeEventAction, - BridgeControllerFetchBatchSellTradesAction, + BridgeControllerUpdateBatchSellTradesAction, } from './bridge-controller-method-action-types'; export { AbortReason } from './utils/metrics/constants'; diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index de765f19a6..c9f96fad49 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -95,7 +95,7 @@ export type TokenAmountValues = { /** * The amount of the token * - * @example "1000000000000000000" + * @example "1.005" */ amount: string; /** diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index f84cdadcea..fd90e8482c 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -1,5 +1,6 @@ import { AddressZero } from '@ethersproject/constants'; import type { CaipAssetType } from '@metamask/utils'; +import { ServerResponse } from 'http'; import mockBridgeQuotesErc20Erc20 from '../../tests/mock-quotes-erc20-erc20.json'; import mockBridgeQuotesNativeErc20 from '../../tests/mock-quotes-native-erc20.json'; @@ -10,6 +11,7 @@ import { fetchBridgeTokens, fetchAssetPrices, fetchBatchSellTrades, + formatBatchSellTradesRequest, } from './fetch'; import { BatchSimulationTransactionType, FeatureId } from './validators'; @@ -732,13 +734,15 @@ describe('fetch', () => { }, }; - // TODO error response it('should fetch batch sell trades', async () => { - mockFetchFn.mockResolvedValue(mockBatchSellTrades); + mockFetchFn.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockBatchSellTrades), + }); const { signal } = new AbortController(); const result = await fetchBatchSellTrades( - { quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[] }, + mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], signal, BridgeClientId.EXTENSION, 'AUTH_TOKEN', @@ -758,9 +762,11 @@ describe('fetch', () => { }, signal, method: 'POST', - body: JSON.stringify({ - quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], - }), + body: JSON.stringify( + formatBatchSellTradesRequest( + mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], + ), + ), }, ); @@ -770,12 +776,15 @@ describe('fetch', () => { }); it('should rethrow fetch error', async () => { - mockFetchFn.mockRejectedValue(new Error('Fetch error')); + mockFetchFn.mockResolvedValue({ + ok: false, + statusText: 'Fetch error', + }); const { signal } = new AbortController(); await expect( fetchBatchSellTrades( - { quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[] }, + mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], signal, BridgeClientId.EXTENSION, 'AUTH_TOKEN', @@ -796,9 +805,11 @@ describe('fetch', () => { }, signal, method: 'POST', - body: JSON.stringify({ - quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], - }), + body: JSON.stringify( + formatBatchSellTradesRequest( + mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], + ), + ), }, ); @@ -808,43 +819,53 @@ describe('fetch', () => { it('should fetch batch sell trades (malformed response)', async () => { mockFetchFn.mockResolvedValueOnce({ - ...mockBatchSellTrades, - transactions: mockBatchSellTrades.transactions.map( - ({ maxFeePerGas, maxPriorityFeePerGas, ...rest }) => rest, - ), + ok: true, + json: jest.fn().mockResolvedValue({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map( + ({ maxFeePerGas, maxPriorityFeePerGas, ...rest }) => rest, + ), + }), }); mockFetchFn.mockResolvedValueOnce({ - ...mockBatchSellTrades, - transactions: mockBatchSellTrades.transactions.map((trade) => ({ - ...trade, - maxFeePerGas: 1000, - maxPriorityFeePerGas: 1000, - })), + ok: true, + json: jest.fn().mockResolvedValue({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map((trade) => ({ + ...trade, + maxFeePerGas: 1000, + maxPriorityFeePerGas: 1000, + })), + }), }); mockFetchFn.mockResolvedValueOnce({ - ...mockBatchSellTrades, - transactions: mockBatchSellTrades.transactions.map((trade) => ({ - ...trade, - maxFeePerGas: '1000', - maxPriorityFeePerGas: '1000', - })), + ok: true, + json: jest.fn().mockResolvedValue({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map((trade) => ({ + ...trade, + maxFeePerGas: '1000', + maxPriorityFeePerGas: '1000', + })), + }), }); mockFetchFn.mockResolvedValueOnce({ - ...mockBatchSellTrades, - transactions: mockBatchSellTrades.transactions.map((trade) => ({ - ...trade, - maxFeePerGas: 0x123, - maxPriorityFeePerGas: 0x456, - })), + ok: true, + json: jest.fn().mockResolvedValue({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map((trade) => ({ + ...trade, + maxFeePerGas: 0x123, + maxPriorityFeePerGas: 0x456, + })), + }), }); const { signal } = new AbortController(); await expect( fetchBatchSellTrades( - { - quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], - }, + [...(mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[]), null], signal, BridgeClientId.EXTENSION, 'AUTH_TOKEN', @@ -857,9 +878,7 @@ describe('fetch', () => { const result = await Promise.allSettled( Array.from({ length: 3 }, () => fetchBatchSellTrades( - { - quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], - }, + mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], signal, BridgeClientId.EXTENSION, 'AUTH_TOKEN', @@ -882,13 +901,16 @@ describe('fetch', () => { }, signal, method: 'POST', - body: JSON.stringify({ - quotes: mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], - }), + body: JSON.stringify( + formatBatchSellTradesRequest( + mockBridgeQuotesErc20Erc20 as unknown as QuoteResponse[], + ), + ), }, ); expect( + // @ts-expect-error - reason is not in type result.map((error) => ({ ...error, reason: error.reason?.message })), ).toMatchInlineSnapshot(` [ diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 6819ad1791..59b7f1c7d8 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -241,7 +241,7 @@ const fetchAssetPricesForCurrency = async (request: { const priceApiResponse = (await fetchFn(url, { headers: getClientHeaders({ clientId, clientVersion }), signal, - })) as Record; + })) as unknown as Record; if (!priceApiResponse || typeof priceApiResponse !== 'object') { return {}; } @@ -493,20 +493,42 @@ export async function fetchBridgeQuoteStream( }); } +export const formatBatchSellTradesRequest = ( + quotes: (QuoteResponse | null)[], +): BatchSellTradesRequest => ({ + quotes: quotes + .filter((quote): quote is QuoteResponse => quote !== null) + .map( + ({ + trade, + approval, + quote, + estimatedProcessingTimeInSeconds, + quoteId, + }) => ({ + trade, + approval, + quote, + estimatedProcessingTimeInSeconds, + quoteId, + }), + ), +}); + /** * Fetches quotes from the bridge-api's getQuote endpoint * - * @param request - The quote request + * @param quotes - The quotes to fetch the gasless transaction data and fees for. May contain null values if a quote is not available for a swap * @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 + * @returns The batch sell trades and the total network fee */ export async function fetchBatchSellTrades( - request: BatchSellTradesRequest, + quotes: (QuoteResponse | null)[], signal: AbortSignal | null, clientId: string, jwt: string | undefined, @@ -515,7 +537,8 @@ export async function fetchBatchSellTrades( clientVersion?: string, ): Promise { const url = `${bridgeApiBaseUrl}/obtainGaslessBatch`; - const batchSellTradesResponse: unknown = await fetchFn(url, { + const request: BatchSellTradesRequest = formatBatchSellTradesRequest(quotes); + const batchSellTradesResponse = await fetchFn(url, { headers: { ...getClientHeaders({ clientId, @@ -529,11 +552,16 @@ export async function fetchBatchSellTrades( body: JSON.stringify(request), }); + if (!batchSellTradesResponse.ok) { + throw new Error( + `Failed to fetch batch sell trades. ${batchSellTradesResponse.statusText}`, + ); + } + try { - if (validateBatchSellTradesResponse(batchSellTradesResponse)) { - return batchSellTradesResponse; - } - throw new Error('Invalid batch simulation response'); + const data = await batchSellTradesResponse.json(); + validateBatchSellTradesResponse(data); + return data; } catch (error: unknown) { // TODO validation failure event throw new Error(`Invalid batch simulation response. ${error?.toString()}`); diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index 5421c5c144..d541f9ede7 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -16,7 +16,6 @@ import type { QuoteResponse, NonEvmFees, TxData, - BatchSellTradesResponse, } from '../types'; import { isNativeAddress, isNonEvmChainId } from './bridge'; import { FeatureId } from './validators'; diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 41f46bf7be..9177075afa 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -471,6 +471,7 @@ export const TronTradeDataSchema = type({ }); export const QuoteResponseSchema = type({ + quoteId: optional(string()), quote: QuoteSchema, estimatedProcessingTimeInSeconds: number(), approval: optional(union([TxDataSchema, TronTradeDataSchema])), From 8aa18b7c96474391725e936a8911698218ca761c Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 15 May 2026 12:15:57 -0700 Subject: [PATCH 05/14] fix: lint --- eslint-suppressions.json | 15 +-------------- packages/bridge-controller/CHANGELOG.md | 1 + .../bridge-controller/src/utils/fetch.test.ts | 1 - packages/bridge-status-controller/CHANGELOG.md | 1 + .../src/utils/transaction.ts | 14 +++++++------- packages/transaction-pay-controller/CHANGELOG.md | 4 ++++ .../src/strategy/bridge/bridge-quotes.ts | 7 +++---- 7 files changed, 17 insertions(+), 26 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cef941a545..7ba0858e66 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -641,11 +641,6 @@ "count": 3 } }, - "packages/bridge-controller/src/types.ts": { - "@typescript-eslint/prefer-enum-initializers": { - "count": 3 - } - }, "packages/bridge-controller/src/utils/assets.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 2 @@ -730,14 +725,6 @@ "count": 5 } }, - "packages/bridge-controller/src/utils/validators.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "id-length": { - "count": 4 - } - }, "packages/bridge-controller/tests/mock-sse.ts": { "id-length": { "count": 2 @@ -2367,4 +2354,4 @@ "count": 10 } } -} +} \ No newline at end of file diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 71f5a7bb24..98d8b4eda5 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING**: Narrow TxData validation from generic string to Hex ([#8805](https://github.com/MetaMask/core/pull/ - Bump `@metamask/assets-controller` from `^7.1.1` to `^7.1.2` ([#8783](https://github.com/MetaMask/core/pull/8783)) - Bump `@metamask/assets-controllers` from `^108.0.0` to `^108.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) - Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index fd90e8482c..af7c53f393 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -1,6 +1,5 @@ import { AddressZero } from '@ethersproject/constants'; import type { CaipAssetType } from '@metamask/utils'; -import { ServerResponse } from 'http'; import mockBridgeQuotesErc20Erc20 from '../../tests/mock-quotes-erc20-erc20.json'; import mockBridgeQuotesNativeErc20 from '../../tests/mock-quotes-native-erc20.json'; diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index a8d25b481f..3e1ffa90ef 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Remove unnecessary type assertions for bridge quotes ([#8805](https://github.com/MetaMask/core/pull/8805)) - Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) - Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index b4a4a39f1c..36cfb379b4 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -114,9 +114,9 @@ export const calculateGasFees = async ( const transactionParams = { ...trade, gas: gasLimit?.toString(), - data: trade.data as `0x${string}`, - to: trade.to as `0x${string}`, - value: trade.value as `0x${string}`, + data: trade.data, + to: trade.to, + value: trade.value, }; const { maxFeePerGas, maxPriorityFeePerGas } = await getTxGasEstimates( messenger, @@ -333,9 +333,9 @@ export const toBatchTxParams = ( ): BatchTransactionParams => { const params = { ...trade, - data: trade.data as Hex, - to: trade.to as Hex, - value: trade.value as Hex, + data: trade.data, + to: trade.to, + value: trade.value, }; if (skipGasFields) { return params; @@ -459,7 +459,7 @@ export const getAddTransactionBatchParams = async ({ networkClientId, requireApproval, origin: 'metamask', - from: trade.from as Hex, + from: trade.from, transactions, }; diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 5495abc37f..072cf92f02 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Polymarket deposit-wallet support to the Relay strategy for `predictWithdraw` transactions, routed via the `isPolymarketDepositWallet` flag on `TransactionConfig` ([#8754](https://github.com/MetaMask/core/pull/8754)) +### Changed + +- Remove unnecessary type assertions for bridge quotes ([#8805](https://github.com/MetaMask/core/pull/8805)) + ## [22.4.0] ### Added diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts index 2ef5223001..1846de2146 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts @@ -6,7 +6,6 @@ import { toChecksumHexAddress, toHex } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; import type { BatchTransaction } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { orderBy } from 'lodash'; @@ -167,10 +166,10 @@ export async function refreshQuote( * @returns Batch transaction. */ function getBatchTransaction(transaction: TxData): BatchTransaction { - const data = transaction.data as Hex; + const { data } = transaction; const gas = transaction.gasLimit ? toHex(transaction.gasLimit) : undefined; - const to = transaction.to as Hex; - const value = transaction.value as Hex; + const { to } = transaction; + const { value } = transaction; return { data, From f526688b071694ca3c76ea9590b4e50af0d335fd Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 15 May 2026 12:20:26 -0700 Subject: [PATCH 06/14] fix: lint --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b7d54f18ed..5ba155be96 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2343,4 +2343,4 @@ "count": 10 } } -} \ No newline at end of file +} From fb8b5c3435df2bd39d21fe000f667fdd2f42fd4c Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 15 May 2026 12:41:14 -0700 Subject: [PATCH 07/14] chore: rm trades from selector --- packages/bridge-controller/CHANGELOG.md | 2 +- .../bridge-controller/src/selectors.test.ts | 75 ++++++++++++++++--- packages/bridge-controller/src/selectors.ts | 16 ++-- 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 2d0fa4e89e..3029dc045e 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implement transaction batch and fee fetching for BatchSell quotes ([#8805](https://github.com/MetaMask/core/pull/8805)) - add new states `batchSellTrades` and `batchSellTradesLoadingStatus` to contain transaction data and its fetch status - support transaction batch data fetching with the new `updateBatchSellTrades` handler. Clients will need to call this whenever the recommended quotes update - - implement `selectBatchSellTrades` selector which returns the ordered list of transactions to submit as a batch, including any transfer transactions required to cover gas costs. This also returns the `totalNetworkFee` provided by the `obtainGaslessBatch` endpoint and its converted values + - implement `selectBatchSellTrades` selector which returns whether a batch is submittable and the `totalNetworkFee` provided by the `obtainGaslessBatch` endpoint and its converted values ### Changed diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 8d03b39c82..1e58a71370 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -1691,7 +1691,6 @@ describe('Bridge Selectors', () => { batchSellTrades: mockBatchSellTrades, }); - expect(result.batchSellTrades).toStrictEqual(mockBatchSellTrades); expect(result.totalNetworkFee).toMatchInlineSnapshot(` { "amount": "0.01", @@ -1707,7 +1706,7 @@ describe('Bridge Selectors', () => { "valueInCurrency": "2", } `); - expect(result.isLoading).toBe(false); + expect(result.isBatchSellTradeAvailable).toBe(true); }); it('should return total network fee (exchange rates are not available)', () => { @@ -1727,7 +1726,6 @@ describe('Bridge Selectors', () => { batchSellTrades: mockBatchSellTrades, }); - expect(result.batchSellTrades).toStrictEqual(mockBatchSellTrades); expect(result.totalNetworkFee).toMatchInlineSnapshot(` { "amount": "0.01", @@ -1743,7 +1741,7 @@ describe('Bridge Selectors', () => { "valueInCurrency": null, } `); - expect(result.isLoading).toBe(false); + expect(result.isBatchSellTradeAvailable).toBe(true); }); it('should return empty data when batch sell trades are not defined', () => { @@ -1763,20 +1761,75 @@ describe('Bridge Selectors', () => { batchSellTrades: null, }); - expect(result.batchSellTrades).toBeNull(); expect(result.totalNetworkFee).toMatchInlineSnapshot(`undefined`); - expect(result.isLoading).toBe(false); + expect(result.isBatchSellTradeAvailable).toBe(false); }); it.each([ - { status: RequestStatus.LOADING, expectedResult: true }, - { status: RequestStatus.FETCHED, expectedResult: false }, + { + status: RequestStatus.LOADING, + transactions: [ + { + chainId: 137, + to: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + from: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + value: '0x0', + data: '0x', + gasLimit: 21000, + effectiveGas: 21000, + maxFeePerGas: '0x5d21dba00', + maxPriorityFeePerGas: '0x5d21dba00', + }, + ], + expectedResult: false, + }, + { + status: RequestStatus.FETCHED, + transactions: [ + { + chainId: 137, + to: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + from: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + value: '0x0', + data: '0x', + gasLimit: 21000, + effectiveGas: 21000, + maxFeePerGas: '0x5d21dba00', + maxPriorityFeePerGas: '0x5d21dba00', + }, + ], + expectedResult: true, + }, + { + status: RequestStatus.FETCHED, + transactions: undefined, + expectedResult: false, + }, + { + status: RequestStatus.FETCHED, + transactions: [], + expectedResult: false, + }, + { + status: RequestStatus.ERROR, + transactions: undefined, + expectedResult: false, + }, ])( 'should return loading state when status is $status', - ({ status, expectedResult }) => { - const { isLoading } = selectBatchSellTrades({ + ({ status, transactions, expectedResult }) => { + const { isBatchSellTradeAvailable } = selectBatchSellTrades({ ...mockState, batchSellTradesLoadingStatus: status, + // @ts-expect-error - test data + batchSellTrades: transactions + ? { + fee: { + amount: '10000', + }, + transactions, + } + : null, assetExchangeRates: { 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { exchangeRate: '1980', @@ -1789,7 +1842,7 @@ describe('Bridge Selectors', () => { }, }); - expect(isLoading).toBe(expectedResult); + expect(isBatchSellTradeAvailable).toBe(expectedResult); }, ); }); diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 204a123673..e951cae73b 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -660,24 +660,28 @@ const selectBatchSellFees = createBridgeSelector( * Selects the batch transactions and fees for a batch of quotes * * @param state - The state of the bridge controller and its dependency controllers - * @returns The ordered list of transactions to submit as a batch, and the total transaction fee. + * @returns The total transaction fees and whether the batch sell trades are submittable. * * @example * ```ts - * const { batchSellTrades, totalNetworkFee, isLoading } = useSelector(state => selectBatchSellTrades(state.metamask)); + * const { totalNetworkFee, isBatchSellTradeAvailable } = useSelector(state => selectBatchSellTrades(state.metamask)); * ``` */ export const selectBatchSellTrades = createBridgeSelector( [ - (state) => state.batchSellTradesLoadingStatus === RequestStatus.LOADING, + (state) => state.batchSellTradesLoadingStatus === RequestStatus.FETCHED, (state) => state.batchSellTrades, selectBatchSellFees, ], - (isLoading, batchSellTrades, batchFees) => { + (isBatchSellTradeAvailable, batchSellTrades, batchFees) => { return { - batchSellTrades, totalNetworkFee: batchFees, - isLoading, + /** + * Whether the batch sell trades have been fetched and transactions are ready to be submitted + */ + isBatchSellTradeAvailable: + isBatchSellTradeAvailable && + Boolean(batchSellTrades?.transactions?.length), }; }, ); From e3acd3475d662b2af657369d95db4e90676d5146 Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 15 May 2026 12:47:04 -0700 Subject: [PATCH 08/14] fix: skip resetting trades when request is aborted --- packages/bridge-controller/CHANGELOG.md | 2 +- packages/bridge-controller/src/bridge-controller.ts | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 3029dc045e..a497ecb205 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implement transaction batch and fee fetching for BatchSell quotes ([#8805](https://github.com/MetaMask/core/pull/8805)) - add new states `batchSellTrades` and `batchSellTradesLoadingStatus` to contain transaction data and its fetch status - support transaction batch data fetching with the new `updateBatchSellTrades` handler. Clients will need to call this whenever the recommended quotes update - - implement `selectBatchSellTrades` selector which returns whether a batch is submittable and the `totalNetworkFee` provided by the `obtainGaslessBatch` endpoint and its converted values + - implement `selectBatchSellTrades` selector which returns whether a batch is submittable, and the `totalNetworkFee` provided by the `obtainGaslessBatch` endpoint and its converted values ### Changed diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 9f1d41ff5e..16887e042b 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -477,10 +477,6 @@ export class BridgeController extends StaticIntervalPollingController { - state.batchSellTrades = DEFAULT_BRIDGE_CONTROLLER_STATE.batchSellTrades; - }); // Ignore abort errors if ( (error as Error).toString().includes('AbortError') || @@ -497,8 +493,10 @@ export class BridgeController extends StaticIntervalPollingController { + // Reset the batch sell trades if the fetch fails to avoid showing stale data + state.batchSellTrades = DEFAULT_BRIDGE_CONTROLLER_STATE.batchSellTrades; + // Update loading status state.batchSellTradesLoadingStatus = RequestStatus.ERROR; }); console.log(`Failed to fetch batch sell trades`, error); From 2d9ee1ae2436eed595fcee3ca9d49ffe020ed700 Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 15 May 2026 12:50:12 -0700 Subject: [PATCH 09/14] fix: changelog --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 33ed454237..3cefaf7fd3 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Remove unnecessary type assertions for bridge quotes ([#8805](https://github.com/MetaMask/core/pull/8805)) - Bump `@metamask/gas-fee-controller` from `^26.2.1` to `^26.2.2` ([#8834](https://github.com/MetaMask/core/pull/8834)) ### Fixed @@ -25,7 +26,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Remove unnecessary type assertions for bridge quotes ([#8805](https://github.com/MetaMask/core/pull/8805)) - Move the Relay gasless execution feature flag to `confirmations_pay_extended.payStrategies.relay.gaslessEnabled` ([#8810](https://github.com/MetaMask/core/pull/8810)) ## [22.4.0] From 75e1224229c0027e61f292ca2e26f37e425bfadc Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 15 May 2026 12:59:51 -0700 Subject: [PATCH 10/14] fix: loading status --- packages/bridge-controller/src/bridge-controller.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 16887e042b..d8eefb0a0a 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -459,6 +459,11 @@ export class BridgeController extends StaticIntervalPollingController { + // Set loading status again if recommended quotes are re-ordered + state.batchSellTradesLoadingStatus = RequestStatus.LOADING; + }); + try { const batchSellTradesResponse = await fetchBatchSellTrades( quotes, From a0e9a36d5262325a510ab1c2f970a8a0af3cea97 Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 15 May 2026 15:22:12 -0700 Subject: [PATCH 11/14] chore: rename --- packages/bridge-controller/src/index.ts | 2 +- packages/bridge-controller/src/selectors.test.ts | 4 ++-- packages/bridge-controller/src/selectors.ts | 4 ++-- packages/bridge-controller/src/utils/fetch.test.ts | 6 +++--- packages/bridge-controller/src/utils/validators.ts | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 34a0b93893..52fb833ab7 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -99,7 +99,7 @@ export { TokenFeatureType, validateQuoteStreamComplete, QuoteStreamCompleteReason, - BatchSimulationTransactionType, + BatchSellTransactionType, } from './utils/validators'; export { diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 1e58a71370..3c5b924e2a 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -27,7 +27,7 @@ import { formatAddressToAssetId, formatChainIdToHex, } from './utils/caip-formatters'; -import { BatchSimulationTransactionType } from './utils/validators'; +import { BatchSellTransactionType } from './utils/validators'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const MOCK_MUSD_ADDRESS = '0x12345A7890123456789012345678901234567890'; @@ -1657,7 +1657,7 @@ describe('Bridge Selectors', () => { effectiveGas: 21000, maxFeePerGas: '0x5d21dba00', maxPriorityFeePerGas: '0x5d21dba00', - type: BatchSimulationTransactionType.TRANSFER, + type: BatchSellTransactionType.TRANSFER, } as const, ], fee: { diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index e951cae73b..c47eeff40d 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -528,8 +528,8 @@ export const selectIsQuoteExpired = createBridgeSelector( (isQuoteGoingToRefresh, quotesLastFetched, refreshRate, currentTimeInMs) => Boolean( !isQuoteGoingToRefresh && - quotesLastFetched && - currentTimeInMs - quotesLastFetched > refreshRate, + quotesLastFetched && + currentTimeInMs - quotesLastFetched > refreshRate, ), ); diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index af7c53f393..85dd6daa74 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -12,7 +12,7 @@ import { fetchBatchSellTrades, formatBatchSellTradesRequest, } from './fetch'; -import { BatchSimulationTransactionType, FeatureId } from './validators'; +import { BatchSellTransactionType, FeatureId } from './validators'; const mockFetchFn = jest.fn(); @@ -708,13 +708,13 @@ describe('fetch', () => { ({ trade, approval }) => [ { ...trade, - type: BatchSimulationTransactionType.TRADE, + type: BatchSellTransactionType.TRADE, maxFeePerGas: '0x123', maxPriorityFeePerGas: '0x456', }, { ...approval, - type: BatchSimulationTransactionType.APPROVAL, + type: BatchSellTransactionType.APPROVAL, maxFeePerGas: '0x123', maxPriorityFeePerGas: '0x456', }, diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 9177075afa..1fcac0a9ea 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -536,7 +536,7 @@ export const validateQuoteStreamComplete = ( return true; }; -export enum BatchSimulationTransactionType { +export enum BatchSellTransactionType { TRADE = 'trade', APPROVAL = 'approval', TRANSFER = 'transfer', @@ -552,7 +552,7 @@ export const BatchSellTradesResponseSchema = type({ intersection([ TxDataSchema, SimulatedGasFeeLimitsSchema, - type({ type: enums(Object.values(BatchSimulationTransactionType)) }), + type({ type: enums(Object.values(BatchSellTransactionType)) }), ]), ), fee: type({ From 28e4c4cc307b8c49eba32eb23c0977f1f7a94d8f Mon Sep 17 00:00:00 2001 From: micaelae Date: Fri, 15 May 2026 15:26:02 -0700 Subject: [PATCH 12/14] chore: optional fee --- packages/bridge-controller/src/utils/validators.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 1fcac0a9ea..33d6a5edd9 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -555,10 +555,12 @@ export const BatchSellTradesResponseSchema = type({ type({ type: enums(Object.values(BatchSellTransactionType)) }), ]), ), - fee: type({ - asset: BridgeAssetSchema, - amount: NumberStringSchema, - }), + fee: optional( + type({ + asset: BridgeAssetSchema, + amount: NumberStringSchema, + }), + ), }); export const validateBatchSellTradesResponse = ( From 40b8f9fd83e316f5f01f13a92f918c639150740a Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 18 May 2026 10:07:40 -0700 Subject: [PATCH 13/14] fix: lint --- packages/bridge-controller/src/selectors.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index c47eeff40d..28714a0f8f 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -641,12 +641,12 @@ export const selectBatchSellQuotes = createStructuredBridgeSelector({ const selectBatchSellFees = createBridgeSelector( [ - (state) => state.batchSellTrades?.fee.amount, - (state) => state.batchSellTrades?.fee.asset, + (state) => state.batchSellTrades?.fee?.amount, + (state) => state.batchSellTrades?.fee?.asset, (state) => selectExchangeRateByAssetId( state, - state.batchSellTrades?.fee.asset?.assetId, + state.batchSellTrades?.fee?.asset?.assetId, ), ], (feeAmount, feeAsset, exchangeRate) => { From 9c38d375e0d468e4f7977f61faa12813d9b2e881 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 18 May 2026 10:15:27 -0700 Subject: [PATCH 14/14] fix: lint --- packages/bridge-controller/src/selectors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 28714a0f8f..c96e7dd799 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -528,8 +528,8 @@ export const selectIsQuoteExpired = createBridgeSelector( (isQuoteGoingToRefresh, quotesLastFetched, refreshRate, currentTimeInMs) => Boolean( !isQuoteGoingToRefresh && - quotesLastFetched && - currentTimeInMs - quotesLastFetched > refreshRate, + quotesLastFetched && + currentTimeInMs - quotesLastFetched > refreshRate, ), );