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/__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..688e0015d5 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..204a123673 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'; @@ -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,49 @@ 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 + * @returns The ordered list of transactions to submit as a batch, and the total transaction fee. + * + * @example + * ```ts + * const { batchSellTrades, totalNetworkFee, isLoading } = 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..f84cdadcea 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -4,12 +4,14 @@ 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, fetchAssetPrices, + fetchBatchSellTrades, } from './fetch'; -import { FeatureId } from './validators'; +import { BatchSimulationTransactionType, FeatureId } from './validators'; 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; +};