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 new file mode 100644 index 0000000000..cebcff58c7 --- /dev/null +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap @@ -0,0 +1,124 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BridgeController Batch SSE should trigger quote polling if request is valid 1`] = ` +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": [ + { + "destChainId": "137", + "destTokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "10", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "100000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + { + "destChainId": "137", + "destTokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "10", + "srcTokenAddress": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "srcTokenAmount": "1000000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + { + "destChainId": "137", + "destTokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "10", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "1000000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + ], + "quoteStreamComplete": null, + "quotes": [], + "quotesInitialLoadTime": null, + "quotesLoadingStatus": 0, + "quotesRefreshCount": 0, + "tokenSecurityTypeDestination": null, + "tokenWarnings": [], +} +`; + +exports[`BridgeController Batch SSE should trigger quote polling if request is valid 2`] = ` +[ + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "input": "chain_source", + "input_value": "eip155:10", + "location": "Main View", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "input": "chain_destination", + "input_value": "eip155:137", + "location": "Main View", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "input": "token_destination", + "input_value": "eip155:137/erc20:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "location": "Main View", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "input": "slippage", + "input_value": 0.5, + "location": "Main View", + }, + ], + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:137", + "chain_id_source": "eip155:10", + "custom_slippage": true, + "has_sufficient_funds": true, + "is_hardware_wallet": false, + "location": "Main View", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "eip155:137/erc20:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "token_address_source": "eip155:10/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], +] +`; 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 534663ef46..5dbe5e22f4 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 @@ -24,7 +24,7 @@ exports[`BridgeController SSE should publish validation failures 4`] = ` "refresh_count": 1, "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", "token_address_source": "eip155:1/slip44:60", - "token_security_type_destination": null, + "token_security_type_destination": "test", }, ], [ @@ -40,7 +40,7 @@ exports[`BridgeController SSE should publish validation failures 4`] = ` "refresh_count": 1, "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", "token_address_source": "eip155:1/slip44:60", - "token_security_type_destination": null, + "token_security_type_destination": "test", }, ], [ @@ -56,7 +56,7 @@ exports[`BridgeController SSE should publish validation failures 4`] = ` "refresh_count": 1, "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", "token_address_source": "eip155:1/slip44:60", - "token_security_type_destination": null, + "token_security_type_destination": "test", }, ], ] @@ -99,7 +99,7 @@ exports[`BridgeController SSE should reset and refetch quotes after quote reques "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, - "has_sufficient_funds": true, + "has_sufficient_funds": false, "is_hardware_wallet": false, "location": "Main View", "security_warnings": [], @@ -181,18 +181,20 @@ exports[`BridgeController SSE should rethrow error from server 1`] = ` }, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": { - "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "destTokenAddress": "123d1", - "destWalletAddress": "SolanaWalletAddres1234", - "insufficientBal": false, - "resetApproval": false, - "slippage": 0.5, - "srcChainId": "0x1", - "srcTokenAddress": "0x0000000000000000000000000000000000000000", - "srcTokenAmount": "1000000000000000000", - "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", - }, + "quoteRequest": [ + { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "1000000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + ], "quoteStreamComplete": null, "quotes": [], "quotesInitialLoadTime": null, @@ -303,18 +305,20 @@ exports[`BridgeController SSE should trigger quote polling if request is valid 1 }, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": { - "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "destTokenAddress": "123d1", - "destWalletAddress": "SolanaWalletAddres1234", - "insufficientBal": false, - "resetApproval": false, - "slippage": 0.5, - "srcChainId": "0x1", - "srcTokenAddress": "0x0000000000000000000000000000000000000000", - "srcTokenAmount": "1000000000000000000", - "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", - }, + "quoteRequest": [ + { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "1000000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + ], "quoteStreamComplete": null, "quotes": [], "quotesInitialLoadTime": null, 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 68b3e6b8a5..ac91fb4f2c 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap @@ -10,16 +10,18 @@ exports[`BridgeController should handle errors from fetchBridgeQuotes 1`] = ` }, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": { - "destChainId": "0x1", - "destTokenAddress": "0x0000000000000000000000000000000000000000", - "insufficientBal": false, - "resetApproval": false, - "srcChainId": "0xa", - "srcTokenAddress": "0x4200000000000000000000000000000000000006", - "srcTokenAmount": "991250000000000000", - "walletAddress": "eip:id/id:id/0x123", - }, + "quoteRequest": [ + { + "destChainId": "0x1", + "destTokenAddress": "0x0000000000000000000000000000000000000000", + "insufficientBal": false, + "resetApproval": false, + "srcChainId": "0xa", + "srcTokenAddress": "0x4200000000000000000000000000000000000006", + "srcTokenAmount": "991250000000000000", + "walletAddress": "eip:id/id:id/0x123", + }, + ], "quoteStreamComplete": null, "quotesInitialLoadTime": 10000, "quotesLoadingStatus": 1, @@ -39,16 +41,18 @@ exports[`BridgeController should handle errors from fetchBridgeQuotes 2`] = ` }, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": { - "destChainId": "0x1", - "destTokenAddress": "0x0000000000000000000000000000000000000000", - "insufficientBal": false, - "resetApproval": false, - "srcChainId": "0xa", - "srcTokenAddress": "0x4200000000000000000000000000000000000006", - "srcTokenAmount": "991250000000000000", - "walletAddress": "eip:id/id:id/0x123", - }, + "quoteRequest": [ + { + "destChainId": "0x1", + "destTokenAddress": "0x0000000000000000000000000000000000000000", + "insufficientBal": false, + "resetApproval": false, + "srcChainId": "0xa", + "srcTokenAddress": "0x4200000000000000000000000000000000000006", + "srcTokenAmount": "991250000000000000", + "walletAddress": "eip:id/id:id/0x123", + }, + ], "quoteStreamComplete": null, "quotesInitialLoadTime": 10000, "quotesLoadingStatus": 1, @@ -503,7 +507,7 @@ exports[`BridgeController updateBridgeQuoteRequestParams should only poll once i "chain_id_destination": "eip155:10", "chain_id_source": "eip155:1", "custom_slippage": true, - "has_sufficient_funds": true, + "has_sufficient_funds": false, "is_hardware_wallet": false, "location": "Main View", "security_warnings": [], @@ -827,17 +831,20 @@ exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote po "assetExchangeRates": {}, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": { - "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "destTokenAddress": "123d1", - "destWalletAddress": "SolanaWalletAddres1234", - "insufficientBal": false, - "slippage": 0.5, - "srcChainId": "0x1", - "srcTokenAddress": "0x0000000000000000000000000000000000000000", - "srcTokenAmount": "10", - "walletAddress": "0x123", - }, + "quoteRequest": [ + { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "10", + "walletAddress": "0x123", + }, + ], "quoteStreamComplete": null, "quotes": [], "quotesInitialLoadTime": null, @@ -859,18 +866,20 @@ exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote po }, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": { - "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "destTokenAddress": "123d1", - "destWalletAddress": "SolanaWalletAddres1234", - "insufficientBal": false, - "resetApproval": false, - "slippage": 0.5, - "srcChainId": "0x1", - "srcTokenAddress": "0x0000000000000000000000000000000000000000", - "srcTokenAmount": "10", - "walletAddress": "0x123", - }, + "quoteRequest": [ + { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "10", + "walletAddress": "0x123", + }, + ], "quoteStreamComplete": null, "quotesInitialLoadTime": 10000, "quotesLoadingStatus": 1, diff --git a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts new file mode 100644 index 0000000000..16a1802da2 --- /dev/null +++ b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts @@ -0,0 +1,420 @@ +import { SolScope } from '@metamask/keyring-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { flushPromises } from '../../../tests/helpers'; +import mockBridgeQuotesErc20Erc20 from '../tests/mock-quotes-erc20-erc20.json'; +import mockBridgeQuotesNativeErc20 from '../tests/mock-quotes-native-erc20.json'; +import { + advanceToNthTimerThenFlush, + mockSseBatchEventSource, +} from '../tests/mock-sse'; +import { BridgeController } from './bridge-controller'; +import { + BridgeClientId, + BRIDGE_PROD_API_BASE_URL, + DEFAULT_BRIDGE_CONTROLLER_STATE, +} from './constants/bridge'; +import { ChainId, RequestStatus } from './types'; +import type { BridgeControllerMessenger, QuoteResponse } from './types'; +import * as balanceUtils from './utils/balance'; +import * as featureFlagUtils from './utils/feature-flags'; +import * as fetchUtils from './utils/fetch'; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS = [ + 'AccountsController:getAccountByAddress', + 'AuthenticationController:getBearerToken', + 'CurrencyRateController:getState', + 'TokenRatesController:getState', + 'MultichainAssetsRatesController:getState', + 'SnapController:handleRequest', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'RemoteFeatureFlagController:getState', + 'AssetsController:getExchangeRatesForBridge', +] as const; + +const messengerCallMock = jest.fn(); +const getLayer1GasFeeMock = jest.fn(); +const mockFetchFn = jest.fn(); +const trackMetaMetricsFn = jest.fn(); + +const quoteRequest = { + srcChainId: '0x1', + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '123d1', + srcTokenAmount: '1000000000000000000', + slippage: 0.5, + walletAddress: '0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294', + destWalletAddress: 'SolanaWalletAddres1234', + resetApproval: false, +}; +const metricsContext = { + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + stx_enabled: true, + security_warnings: [], + warnings: [], + token_security_type_destination: null, +}; + +const assetExchangeRates = { + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + exchangeRate: undefined, + usdExchangeRate: '100', + }, +}; + +type WithControllerCallback = (payload: { + controller: BridgeController; + rootMessenger: RootMessenger; + stopAllPollingSpy: jest.SpyInstance; + startPollingSpy: jest.SpyInstance; + hasSufficientBalanceSpy: jest.SpyInstance; + fetchBridgeQuotesSpy: jest.SpyInstance; + fetchAssetPricesSpy: jest.SpyInstance; + consoleLogSpy: jest.SpyInstance; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const messenger: BridgeControllerMessenger = new Messenger({ + namespace: 'BridgeController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [...BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS], + }); + + for (const action of BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS) { + rootMessenger.registerActionHandler(action, (...actionArgs) => + messengerCallMock(action, ...actionArgs), + ); + } + + jest.useFakeTimers(); + + getLayer1GasFeeMock.mockResolvedValue('0x1'); + + messengerCallMock.mockImplementation( + (...messengerArgs: Parameters) => { + switch (messengerArgs[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + currencyRates: {}, + marketData: {}, + conversionRates: {}, + }; + } + }, + ); + + jest.spyOn(featureFlagUtils, 'getBridgeFeatureFlags').mockReturnValue({ + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + sse: { + enabled: true, + minimumVersion: '13.8.0', + }, + chains: { + '10': { isActiveSrc: true, isActiveDest: false }, + '534352': { isActiveSrc: true, isActiveDest: false }, + '137': { isActiveSrc: false, isActiveDest: true }, + '42161': { isActiveSrc: false, isActiveDest: true }, + [ChainId.SOLANA]: { + isActiveSrc: true, + isActiveDest: true, + }, + }, + chainRanking: [{ chainId: 'eip155:1' as const, name: 'Ethereum' }], + }); + + const controller = new BridgeController({ + messenger, + getLayer1GasFee: getLayer1GasFeeMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + trackMetaMetricsFn, + clientVersion: '13.8.0', + ...options, + }); + + const stopAllPollingSpy = jest.spyOn(controller, 'stopAllPolling'); + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + const fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuoteStream'); + const fetchAssetPricesSpy = jest + .spyOn(fetchUtils, 'fetchAssetPrices') + .mockResolvedValue({ + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + usd: '100', + }, + }); + + const consoleLogSpy = jest.spyOn(console, 'log'); + + return await testFunction({ + controller, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }); +} + +describe('BridgeController Batch SSE', 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 mockSseBatchEventSource([ + mockBridgeQuotesNativeErc20 as QuoteResponse[], + mockBridgeQuotesErc20Erc20 as QuoteResponse[], + ]); + }); + hasSufficientBalanceSpy.mockResolvedValue(true); + + 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', + quoteRequest1, + metricsContext, + 1, + 2, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest0, + metricsContext, + 0, + 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(4); + expect(startPollingSpy).toHaveBeenCalledTimes(3); + expect( + startPollingSpy.mock.calls + .map((call) => call[0].updatedQuoteRequest) + .flat() + .find((q) => !q), + ).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: [ + { ...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(3); + 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, + { + 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: [ + { + ...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, + })), + ), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + assetExchangeRates, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(3); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }, + ); + }); +}); diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index d53929d027..0c10142524 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -87,6 +87,7 @@ const metricsContext = { stx_enabled: true, security_warnings: [], warnings: [], + token_security_type_destination: null, }; const assetExchangeRates = { @@ -255,17 +256,18 @@ describe('BridgeController SSE', function () { expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: false, + }, + ], context: metricsContext, }); expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quoteRequest, + quoteRequest: [{ ...quoteRequest, insufficientBal: false }], quotesLoadingStatus: RequestStatus.LOADING, }; expect(bridgeController.state).toStrictEqual(expectedState); @@ -275,11 +277,13 @@ describe('BridgeController SSE', function () { await advanceToNthTimerThenFlush(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( mockFetchFn, - { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], expect.any(AbortSignal), BridgeClientId.EXTENSION, 'AUTH_TOKEN', @@ -306,11 +310,13 @@ describe('BridgeController SSE', function () { expect(bridgeController.state).toStrictEqual({ ...expectedState, quotesInitialLoadTime: 6000, - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], quotes: mockBridgeQuotesNativeErc20.map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', @@ -413,16 +419,20 @@ describe('BridgeController SSE', function () { expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...usdtQuoteRequest, - insufficientBal: false, - resetApproval, - }, + quoteRequests: [ + { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + ], context: metricsContext, }); const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quoteRequest: usdtQuoteRequest, + quoteRequest: [ + { ...usdtQuoteRequest, insufficientBal: false, resetApproval }, + ], quotesLoadingStatus: RequestStatus.LOADING, }; expect(bridgeController.state).toStrictEqual(expectedState); @@ -432,11 +442,13 @@ describe('BridgeController SSE', function () { await advanceToNthTimerThenFlush(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( mockFetchFn, - { - ...usdtQuoteRequest, - insufficientBal: false, - resetApproval, - }, + [ + { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + ], expect.any(AbortSignal), BridgeClientId.EXTENSION, 'AUTH_TOKEN', @@ -452,11 +464,13 @@ describe('BridgeController SSE', function () { ); const { quotesLastFetched: t1, quoteRequest: stateQuoteRequest } = bridgeController.state; - expect(stateQuoteRequest).toStrictEqual({ - ...usdtQuoteRequest, - insufficientBal: false, - resetApproval, - }); + expect(stateQuoteRequest).toStrictEqual([ + { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + ]); expect(t1).toBeCloseTo(Date.now() - 1000); // After first fetch @@ -465,11 +479,13 @@ describe('BridgeController SSE', function () { expect(bridgeController.state).toStrictEqual({ ...expectedState, quotesInitialLoadTime: 6000, - quoteRequest: { - ...usdtQuoteRequest, - insufficientBal: false, - resetApproval, - }, + quoteRequest: [ + { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + ], quotes: mockUSDTQuoteResponse.map((quote) => ({ ...quote, resetApproval: tradeData @@ -561,16 +577,20 @@ describe('BridgeController SSE', function () { expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...usdtQuoteRequest, - insufficientBal: true, - resetApproval: true, - }, + quoteRequests: [ + { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + ], context: metricsContext, }); const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quoteRequest: usdtQuoteRequest, + quoteRequest: [ + { ...usdtQuoteRequest, insufficientBal: true, resetApproval: true }, + ], quotesLoadingStatus: RequestStatus.LOADING, }; expect(bridgeController.state).toStrictEqual(expectedState); @@ -581,11 +601,13 @@ describe('BridgeController SSE', function () { await advanceToNthTimerThenFlush(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( mockFetchFn, - { - ...usdtQuoteRequest, - insufficientBal: true, - resetApproval: true, - }, + [ + { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + ], expect.any(AbortSignal), BridgeClientId.EXTENSION, 'AUTH_TOKEN', @@ -601,7 +623,7 @@ describe('BridgeController SSE', function () { ); const { quotesLastFetched: t1, quoteRequest: stateQuoteRequest } = bridgeController.state; - expect(stateQuoteRequest).toStrictEqual({ + expect(stateQuoteRequest[0]).toStrictEqual({ ...usdtQuoteRequest, insufficientBal: true, resetApproval: true, @@ -614,11 +636,13 @@ describe('BridgeController SSE', function () { expect(bridgeController.state).toStrictEqual({ ...expectedState, quotesInitialLoadTime: 6000, - quoteRequest: { - ...usdtQuoteRequest, - insufficientBal: true, - resetApproval: true, - }, + quoteRequest: [ + { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + ], quotes: mockUSDTQuoteResponse.map((quote) => ({ ...quote, resetApproval: { @@ -690,11 +714,13 @@ describe('BridgeController SSE', function () { const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, quotesInitialLoadTime: FIRST_FETCH_DELAY, - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], quotes: [mockBridgeQuotesNativeErc20Eth[0]].map((quote) => ({ ...quote, resetApproval: undefined, @@ -803,11 +829,13 @@ describe('BridgeController SSE', function () { expect(bridgeController.state).toStrictEqual({ ...DEFAULT_BRIDGE_CONTROLLER_STATE, quotesInitialLoadTime: FIRST_FETCH_DELAY, - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], quotes: [], quotesLoadingStatus: 2, quoteFetchError: 'Network error', @@ -910,10 +938,13 @@ describe('BridgeController SSE', function () { const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, quotesLoadingStatus: RequestStatus.LOADING, - quoteRequest: { - ...quoteRequest, - srcTokenAmount: '10', - }, + quoteRequest: [ + { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: true, + }, + ], assetExchangeRates: {}, }; // Start new quote request @@ -926,6 +957,7 @@ describe('BridgeController SSE', function () { token_symbol_destination: 'USDC', security_warnings: [], usd_amount_source: 100, + token_security_type_destination: null, }, ); // Right after state update, before fetch has started @@ -933,12 +965,14 @@ describe('BridgeController SSE', function () { advanceToNthTimer(); expect(bridgeController.state).toStrictEqual({ ...expectedState, - quoteRequest: { - ...quoteRequest, - srcTokenAmount: '10', - insufficientBal: true, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: true, + resetApproval: false, + }, + ], quotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.LOADING, }); @@ -959,12 +993,14 @@ describe('BridgeController SSE', function () { ], quotesRefreshCount: 0, quotesLoadingStatus: RequestStatus.LOADING, - quoteRequest: { - ...quoteRequest, - srcTokenAmount: '10', - insufficientBal: true, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: true, + resetApproval: false, + }, + ], quotesLastFetched: t1, assetExchangeRates, }; @@ -1105,6 +1141,7 @@ describe('BridgeController SSE', function () { token_symbol_destination: 'USDC', security_warnings: [], usd_amount_source: 100, + token_security_type_destination: 'test', }, ); @@ -1150,12 +1187,14 @@ describe('BridgeController SSE', function () { const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, quotesInitialLoadTime: 2000, - quoteRequest: { - ...quoteRequest, - srcTokenAmount: '10', - insufficientBal: false, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: false, + resetApproval: false, + }, + ], quotes: [mockBridgeQuotesNativeErc20Eth[0]].map((quote) => ({ ...quote, resetApproval: undefined, @@ -1165,6 +1204,7 @@ describe('BridgeController SSE', function () { quotesLoadingStatus: RequestStatus.LOADING, assetExchangeRates, quotesLastFetched: expect.any(Number), + tokenSecurityTypeDestination: 'test', }; const t6 = bridgeController.state.quotesLastFetched; expect(t6).toBeCloseTo(Date.now() - 2000); @@ -1263,16 +1303,24 @@ describe('BridgeController SSE', function () { expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], context: metricsContext, }); const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, - quoteRequest, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], assetExchangeRates: {}, quotesLoadingStatus: RequestStatus.LOADING, }; @@ -1282,13 +1330,19 @@ describe('BridgeController SSE', function () { jest.advanceTimersByTime(1000); // Wait for JWT token retrieval await advanceToNthTimerThenFlush(); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.LOADING, + ); expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( mockFetchFn, - { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], expect.any(AbortSignal), BridgeClientId.EXTENSION, 'AUTH_TOKEN', @@ -1315,11 +1369,13 @@ describe('BridgeController SSE', function () { expect(bridgeController.state).toStrictEqual({ ...expectedState, assetExchangeRates, - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], quotesRefreshCount: 1, quotesLoadingStatus: 2, quoteFetchError: 'Bridge-api error: timeout from server', diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 970f952435..c8dc093347 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -524,26 +524,38 @@ describe('BridgeController', function () { await rootMessenger.call( 'BridgeController:updateBridgeQuoteRequestParams', - { srcChainId: 1, walletAddress: '0x123' }, + { + srcChainId: 1, + walletAddress: '0x123', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }, metricsContext, ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ - walletAddress: '0x123', - srcChainId: 1, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - }); + expect(bridgeController.state.quoteRequest).toStrictEqual([ + { + walletAddress: '0x123', + srcChainId: 1, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }, + ]); expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); await rootMessenger.call( 'BridgeController:updateBridgeQuoteRequestParams', - { destChainId: 10, walletAddress: '0x123' }, + { + destChainId: 10, + walletAddress: '0x123', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }, metricsContext, ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ - walletAddress: '0x123', - destChainId: 10, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - }); + expect(bridgeController.state.quoteRequest).toStrictEqual([ + { + walletAddress: '0x123', + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }, + ]); await rootMessenger.call( 'BridgeController:updateBridgeQuoteRequestParams', @@ -553,10 +565,9 @@ describe('BridgeController', function () { }, metricsContext, ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ + expect(bridgeController.state.quoteRequest[0]).toStrictEqual({ walletAddress: '0x123abc', destChainId: undefined, - srcTokenAddress: '0x0000000000000000000000000000000000000000', }); await rootMessenger.call( @@ -567,7 +578,7 @@ describe('BridgeController', function () { }, metricsContext, ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ + expect(bridgeController.state.quoteRequest[0]).toStrictEqual({ walletAddress: '0x123', srcTokenAddress: undefined, }); @@ -583,7 +594,7 @@ describe('BridgeController', function () { }, metricsContext, ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ + expect(bridgeController.state.quoteRequest[0]).toStrictEqual({ walletAddress: '0x123', srcTokenAmount: '100000', destTokenAddress: '0x123', @@ -599,13 +610,13 @@ describe('BridgeController', function () { }, metricsContext, ); - expect(bridgeController.state.quoteRequest).toStrictEqual({ + expect(bridgeController.state.quoteRequest[0]).toStrictEqual({ walletAddress: '0x123', srcTokenAddress: '0x2ABC', }); rootMessenger.call('BridgeController:resetState'); - expect(bridgeController.state.quoteRequest).toStrictEqual({ + expect(bridgeController.state.quoteRequest[0]).toStrictEqual({ srcTokenAddress: '0x0000000000000000000000000000000000000000', }); @@ -720,18 +731,25 @@ describe('BridgeController', function () { expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], context: metricsContext, }); expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: '0x123' }, + quoteRequest: [ + expect.objectContaining({ + ...quoteRequest, + walletAddress: '0x123', + }), + ], quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, @@ -859,18 +877,25 @@ describe('BridgeController', function () { expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], context: metricsContext, }); expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: '0x123' }, + quoteRequest: [ + expect.objectContaining({ + ...quoteRequest, + walletAddress: '0x123', + }), + ], quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, @@ -903,11 +928,13 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], quotes: [], quotesLoadingStatus: 0, }), @@ -918,11 +945,13 @@ describe('BridgeController', function () { await flushPromises(); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, }), @@ -938,11 +967,13 @@ describe('BridgeController', function () { await flushPromises(); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], quotes: [ ...mockBridgeQuotesNativeErc20Eth, ...mockBridgeQuotesNativeErc20Eth, @@ -965,11 +996,13 @@ describe('BridgeController', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: false, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], quotes: [], quotesLoadingStatus: 2, quoteFetchError: 'Network error', @@ -1173,7 +1206,7 @@ describe('BridgeController', function () { // Initial state check expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteParams }, + quoteRequest: [expect.objectContaining(quoteParams)], minimumBalanceForRentExemptionInLamports: '0', quotesLoadingStatus: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, @@ -1207,11 +1240,13 @@ describe('BridgeController', function () { nonEvmFeesInNative: '0.000000014', })), quotesLoadingStatus: RequestStatus.FETCHED, - quoteRequest: { - ...quoteParams, - resetApproval: false, - insufficientBal: undefined, - }, + quoteRequest: [ + { + ...quoteParams, + resetApproval: false, + insufficientBal: undefined, + }, + ], quoteFetchError: null, assetExchangeRates: {}, quotesRefreshCount: 1, @@ -1282,11 +1317,13 @@ describe('BridgeController', function () { nonEvmFeesInNative: '0.000000014', })), quotesLoadingStatus: RequestStatus.FETCHED, - quoteRequest: { - ...quoteParams, - resetApproval: false, - insufficientBal: undefined, - }, + quoteRequest: [ + { + ...quoteParams, + resetApproval: false, + insufficientBal: undefined, + }, + ], quoteFetchError: null, assetExchangeRates: {}, quotesRefreshCount: expect.any(Number), @@ -1329,12 +1366,14 @@ describe('BridgeController', function () { nonEvmFeesInNative: '0.000000014', })), quotesLoadingStatus: RequestStatus.FETCHED, - quoteRequest: { - ...quoteParams, - srcTokenAmount: '11111', - insufficientBal: undefined, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteParams, + srcTokenAmount: '11111', + insufficientBal: undefined, + resetApproval: false, + }, + ], quoteFetchError: null, assetExchangeRates: {}, quotesRefreshCount: 1, @@ -1446,18 +1485,20 @@ describe('BridgeController', function () { expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], context: metricsContext, }); expect(fetchAssetPricesSpy).not.toHaveBeenCalled(); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest, + quoteRequest: [expect.objectContaining(quoteRequest)], quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, @@ -1491,11 +1532,13 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], quotes: [], quotesLoadingStatus: 0, quotesLastFetched: t1, @@ -1507,11 +1550,13 @@ describe('BridgeController', function () { await flushPromises(); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, @@ -1546,11 +1591,13 @@ describe('BridgeController', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, @@ -1669,11 +1716,13 @@ describe('BridgeController', function () { expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(hasSufficientBalanceSpy).not.toHaveBeenCalled(); expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], context: metricsContext, }); @@ -1686,11 +1735,13 @@ describe('BridgeController', function () { await flushPromises(); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, @@ -1734,14 +1785,16 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - srcChainId: 1, - slippage: 0.5, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - walletAddress: '0x123WalletAddress', - destChainId: 10, - destTokenAddress: '0x123', - }, + quoteRequest: [ + { + srcChainId: 1, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: '0x123WalletAddress', + destChainId: 10, + destTokenAddress: '0x123', + }, + ], quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, @@ -1784,14 +1837,16 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - srcChainId: 1, - slippage: 0.5, - srcTokenAddress: '0x0000000000000000000000000000000000000000', - walletAddress: '0xabcWalletAddress', - destChainId: ChainId.SOLANA, - destTokenAddress: '0x123', - }, + quoteRequest: [ + { + srcChainId: 1, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: '0xabcWalletAddress', + destChainId: ChainId.SOLANA, + destTokenAddress: '0x123', + }, + ], quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, @@ -2057,17 +2112,19 @@ describe('BridgeController', function () { expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledWith({ - updatedQuoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], context: metricsContext, }); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest, + quoteRequest: [expect.objectContaining(quoteRequest)], quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, @@ -2099,11 +2156,13 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], quotes: [], quotesLoadingStatus: 0, }), @@ -2116,11 +2175,13 @@ describe('BridgeController', function () { expect(quotes).toHaveLength(expectedQuotesLength); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ - quoteRequest: { - ...quoteRequest, - insufficientBal: true, - resetApproval: false, - }, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], quotesLoadingStatus: 1, quotesRefreshCount: 1, }), @@ -3472,15 +3533,17 @@ describe('BridgeController', function () { options: { clientVersion: '1.0.0', state: { - quoteRequest: { - srcChainId: SolScope.Mainnet, - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x1234', - srcTokenAmount: '1000000', - walletAddress: '0x123', - slippage: 0.5, - }, + quoteRequest: [ + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + }, + ], quotes: mockBridgeQuotesSolErc20 as never, }, }, @@ -3518,15 +3581,17 @@ describe('BridgeController', function () { options: { clientVersion: '1.0.0', state: { - quoteRequest: { - srcChainId: SolScope.Mainnet, - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x1234', - srcTokenAmount: '1000000', - walletAddress: '0x123', - slippage: 0.5, - }, + quoteRequest: [ + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + }, + ], quotes: mockBridgeQuotesSolErc20 as never, }, }, @@ -3999,9 +4064,11 @@ describe('BridgeController', function () { "assetExchangeRates": {}, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": { - "srcTokenAddress": "0x0000000000000000000000000000000000000000", - }, + "quoteRequest": [ + { + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + }, + ], "quoteStreamComplete": null, "quotes": [], "quotesInitialLoadTime": null, @@ -4040,9 +4107,11 @@ describe('BridgeController', function () { "assetExchangeRates": {}, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": { - "srcTokenAddress": "0x0000000000000000000000000000000000000000", - }, + "quoteRequest": [ + { + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + }, + ], "quoteStreamComplete": null, "quotes": [], "quotesInitialLoadTime": null, diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 1f63ae0562..35222945f0 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -80,7 +80,7 @@ import type { RequiredEventContextFromClient, } from './utils/metrics/types'; import type { CrossChainSwapsEventProperties } from './utils/metrics/types'; -import { isValidQuoteRequest, sortQuotes } from './utils/quote'; +import { isValidQuoteRequestBatch, sortQuotes } from './utils/quote'; import { appendFeesToQuotes } from './utils/quote-fees'; import { getMinimumBalanceForRentExemptionInLamports } from './utils/snaps'; import type { FeatureId } from './utils/validators'; @@ -168,7 +168,7 @@ const metadata: StateMetadata = { * controller and need to be provided by the client for analytics */ type BridgePollingInput = { - updatedQuoteRequest: GenericQuoteRequest; + quoteRequests: GenericQuoteRequest[]; context: Pick< RequiredEventContextFromClient, UnifiedSwapBridgeEventName.QuotesError @@ -315,62 +315,54 @@ export class BridgeController extends StaticIntervalPollingController & { walletAddress: GenericQuoteRequest['walletAddress']; }, context: BridgePollingInput['context'], + quoteRequestIndex: number = 0, + quoteRequestCount: number = 1, ) => { - this.#trackInputChangedEvents(paramsToUpdate); - this.resetState(AbortReason.QuoteRequestUpdated); - const updatedQuoteRequest = { - ...DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest, - ...paramsToUpdate, - }; + this.#trackInputChangedEvents(paramsToUpdate, quoteRequestIndex); + this.resetState(AbortReason.QuoteRequestUpdated, quoteRequestIndex); this.update((state) => { - state.quoteRequest = updatedQuoteRequest; + state.quoteRequest = state.quoteRequest + .slice(0, quoteRequestIndex) + .concat(paramsToUpdate) + .concat( + state.quoteRequest.slice(quoteRequestIndex + 1, quoteRequestCount), + ); state.tokenSecurityTypeDestination = context.token_security_type_destination ?? null; }); - if (isValidQuoteRequest(updatedQuoteRequest)) { + if (isValidQuoteRequestBatch(this.state.quoteRequest)) { this.#quotesFirstFetched = Date.now(); - const isSrcChainNonEVM = isNonEvmChainId(updatedQuoteRequest.srcChainId); - const providerConfig = isSrcChainNonEVM - ? undefined - : this.#getNetworkClientByChainId( - formatChainIdToHex(updatedQuoteRequest.srcChainId), - )?.configuration; - - let insufficientBal: boolean | undefined; - let resetApproval: boolean = Boolean(paramsToUpdate.resetApproval); - if (isSrcChainNonEVM) { - // If the source chain is not an EVM network, use value from params - insufficientBal = paramsToUpdate.insufficientBal; - } else if (providerConfig?.rpcUrl?.includes('tenderly')) { - // If the rpcUrl is a tenderly fork (e2e tests), set insufficientBal=true - // The bridge-api filters out quotes if the balance on mainnet is insufficient so this override allows quotes to always be returned - insufficientBal = true; - } else { - // Set loading status if RPC calls are made before the quotes are fetched - this.update((state) => { - state.quotesLoadingStatus = RequestStatus.LOADING; - }); - resetApproval = await this.#shouldResetApproval(updatedQuoteRequest); - // Otherwise query the src token balance from the RPC provider - insufficientBal = - paramsToUpdate.insufficientBal ?? - (await this.#hasInsufficientBalance(updatedQuoteRequest)); - } // Set refresh rate based on the source chain before starting polling this.setChainIntervalLength(); + + // Update the insufficientBal and resetApproval params for the quote request + const quoteWithInsufficientBalAndResetApproval = + await this.#appendInsufficientBalAndResetApproval( + this.state.quoteRequest[quoteRequestIndex], + ); + this.update((state) => { + state.quoteRequest[quoteRequestIndex] = + quoteWithInsufficientBalAndResetApproval; + }); + this.startPolling({ - updatedQuoteRequest: { - ...updatedQuoteRequest, - insufficientBal, - resetApproval, - }, + quoteRequests: this.state.quoteRequest, context, }); } @@ -457,20 +449,16 @@ export class BridgeController extends StaticIntervalPollingController) => { + readonly #fetchAssetExchangeRates = async ( + quoteRequests: Partial[], + ) => { const assetIds: Set = new Set([]); const exchangeRateSources = this.#getExchangeRateSources(); + const { srcChainId, srcTokenAddress, destChainId, destTokenAddress } = + // TODO fetch rates for all quote requests + quoteRequests[0]; if ( srcTokenAddress && srcChainId && @@ -555,6 +543,44 @@ export class BridgeController extends StaticIntervalPollingController { + const isSrcChainNonEVM = isNonEvmChainId(quoteRequest.srcChainId); + const providerConfig = isSrcChainNonEVM + ? undefined + : this.#getNetworkClientByChainId( + formatChainIdToHex(quoteRequest.srcChainId), + )?.configuration; + + let insufficientBal: boolean | undefined; + let resetApproval: boolean = Boolean(quoteRequest.resetApproval); + if (isSrcChainNonEVM) { + // If the source chain is not an EVM network, use value from params + insufficientBal = quoteRequest.insufficientBal; + } else if (providerConfig?.rpcUrl?.includes('tenderly')) { + // If the rpcUrl is a tenderly fork (e2e tests), set insufficientBal=true + // The bridge-api filters out quotes if the balance on mainnet is insufficient so this override allows quotes to always be returned + insufficientBal = true; + } else { + // Set loading status if RPC calls are made before the quotes are fetched + this.update((state) => { + state.quotesLoadingStatus = RequestStatus.LOADING; + }); + resetApproval = await this.#shouldResetApproval(quoteRequest); + // Otherwise query the src token balance from the RPC provider + insufficientBal = + quoteRequest.insufficientBal ?? + (await this.#hasInsufficientBalance(quoteRequest)); + } + + return { + ...quoteRequest, + insufficientBal, + resetApproval, + }; + }; + readonly #shouldResetApproval = async (quoteRequest: GenericQuoteRequest) => { if (isNonEvmChainId(quoteRequest.srcChainId)) { return false; @@ -608,11 +634,24 @@ export class BridgeController extends StaticIntervalPollingController { + resetState = ( + reason = AbortReason.ResetState, + quoteRequestIndex: number | null = null, + ) => { this.stopPollingForQuotes(reason); this.update((state) => { + // Clear all requests if index is null + const quoteRequests = + quoteRequestIndex === null + ? DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest + : [...state.quoteRequest]; + // Otherwise only clear the specified request + if (quoteRequestIndex !== null) { + quoteRequests[quoteRequestIndex] = + DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest[0]; + } // Cannot do direct assignment to state, i.e. state = {... }, need to manually assign each field - state.quoteRequest = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest; + state.quoteRequest = quoteRequests; state.quotesInitialLoadTime = DEFAULT_BRIDGE_CONTROLLER_STATE.quotesInitialLoadTime; state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; @@ -640,7 +679,8 @@ export class BridgeController extends StaticIntervalPollingController { const { state } = this; - const { srcChainId } = state.quoteRequest; + // Batch requests are all in the same chain. Use the first one to determine refresh rate + const { srcChainId } = state.quoteRequest[0]; const bridgeFeatureFlags = getBridgeFeatureFlags(this.messenger); const refreshRateOverride = srcChainId @@ -651,13 +691,13 @@ export class BridgeController extends StaticIntervalPollingController { this.#abortController?.abort(AbortReason.NewQuoteRequest); this.#abortController = new AbortController(); - this.#fetchAssetExchangeRates(updatedQuoteRequest).catch((error) => + this.#fetchAssetExchangeRates(quoteRequests).catch((error) => console.warn('Failed to fetch asset exchange rates', error), ); @@ -672,7 +712,6 @@ export class BridgeController extends StaticIntervalPollingController { - state.quoteRequest = updatedQuoteRequest; state.quoteFetchError = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteFetchError; state.tokenWarnings = DEFAULT_BRIDGE_CONTROLLER_STATE.tokenWarnings; state.quoteStreamComplete = @@ -681,6 +720,8 @@ export class BridgeController extends StaticIntervalPollingController { @@ -814,7 +855,7 @@ export class BridgeController extends StaticIntervalPollingController => { - const { walletAddress } = this.state.quoteRequest; + const quoteRequest = this.state.quoteRequest[quoteRequestIndex]; + const { walletAddress } = quoteRequest; const accountHardwareType = getAccountHardwareType( walletAddress ? this.#getMultichainSelectedAccount(walletAddress) @@ -964,9 +1010,9 @@ export class BridgeController extends StaticIntervalPollingController[EventName], + quoteRequestIndex: number = 0, ): CrossChainSwapsEventProperties => { const clientProps = propertiesFromClient as Record; const baseProperties = { @@ -1004,11 +1051,12 @@ export class BridgeController extends StaticIntervalPollingController, + quoteRequestIndex: number = 0, ) => { Object.entries(paramsToUpdate).forEach(([key, value]) => { const inputKey = toInputChangedPropertyKey[key as keyof QuoteRequest]; @@ -1123,7 +1172,11 @@ export class BridgeController extends StaticIntervalPollingController[EventName], + quoteRequestIndex: number = 0, ) => { try { const combinedPropertiesForEvent = this.#getEventProperties( eventName, propertiesFromClient, + quoteRequestIndex, ); this.#trackMetaMetricsFn(eventName, combinedPropertiesForEvent); diff --git a/packages/bridge-controller/src/constants/bridge.ts b/packages/bridge-controller/src/constants/bridge.ts index ac3dfffe6d..6ee92f052a 100644 --- a/packages/bridge-controller/src/constants/bridge.ts +++ b/packages/bridge-controller/src/constants/bridge.ts @@ -79,9 +79,11 @@ export const DEFAULT_FEATURE_FLAG_CONFIG: FeatureFlagsPlatformConfig = { }; export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { - quoteRequest: { - srcTokenAddress: AddressZero, - }, + quoteRequest: [ + { + srcTokenAddress: AddressZero, + }, + ], quotesInitialLoadTime: null, quotes: [], quotesLastFetched: null, diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index eb42636685..419df8628a 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -148,6 +148,7 @@ export { export { isValidQuoteRequest, + isValidQuoteRequestBatch, formatEtaInMinutes, calcSlippagePercentage, } from './utils/quote'; @@ -173,6 +174,7 @@ export { export { selectBridgeQuotes, + selectBridgeQuotesBatch, selectDefaultSlippagePercentage, type BridgeAppState, selectExchangeRateByChainIdAndAddress, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 659f3df95a..f45ac9f32f 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -239,13 +239,15 @@ describe('Bridge Selectors', () => { describe('selectIsQuoteExpired', () => { const mockState = { quotes: [], - quoteRequest: { - srcChainId: '1', - destChainId: '137', - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x0000000000000000000000000000000000000000', - insufficientBal: false, - }, + quoteRequest: [ + { + srcChainId: '1', + destChainId: '137', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x0000000000000000000000000000000000000000', + insufficientBal: false, + }, + ], quotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, quoteFetchError: null, @@ -344,10 +346,12 @@ describe('Bridge Selectors', () => { it('should handle quote expiration when srcChainId is unset', () => { const stateWithOldQuote = { ...mockState, - quoteRequest: { - ...mockState.quoteRequest, - srcChainId: undefined, - }, + quoteRequest: [ + { + ...mockState.quoteRequest, + srcChainId: undefined, + }, + ], quotesRefreshCount: 5, quotesLastFetched: Date.now() - 40000, // 40 seconds ago remoteFeatureFlags: { @@ -432,13 +436,15 @@ describe('Bridge Selectors', () => { }, }, ], - quoteRequest: { - srcChainId: '1', - destChainId: '137', - srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x0000000000000000000000000000000000000000', - insufficientBal: false, - }, + quoteRequest: [ + { + srcChainId: '1', + destChainId: '137', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x0000000000000000000000000000000000000000', + insufficientBal: false, + }, + ], quotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, quoteFetchError: null, @@ -658,13 +664,15 @@ describe('Bridge Selectors', () => { ], currencyRates, marketData, - quoteRequest: { - ...mockState.quoteRequest, - srcChainId: chainId, - destChainId: chainId, - srcTokenAddress: srcAsset.address, - destTokenAddress: destAsset.address, - }, + quoteRequest: [ + { + ...mockState.quoteRequest, + srcChainId: chainId, + destChainId: chainId, + srcTokenAddress: srcAsset.address, + destTokenAddress: destAsset.address, + }, + ], }; }; @@ -1217,7 +1225,7 @@ describe('Bridge Selectors', () => { const result = selectBridgeQuotes( { ...mockState, - quoteRequest: { ...mockState.quoteRequest, insufficientBal: true }, + quoteRequest: [{ ...mockState.quoteRequest, insufficientBal: true }], }, mockClientParams, ); @@ -1339,11 +1347,13 @@ describe('Bridge Selectors', () => { const solanaState = { ...mockState, quotes: [solanaQuote], - quoteRequest: { - ...mockState.quoteRequest, - srcChainId: ChainId.SOLANA, - srcTokenAddress: 'solanaNativeAddress', - }, + quoteRequest: [ + { + ...mockState.quoteRequest, + srcChainId: ChainId.SOLANA, + srcTokenAddress: 'solanaNativeAddress', + }, + ], } as unknown as BridgeAppState; const result = selectBridgeQuotes(solanaState, mockClientParams); diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 33b285758b..aad69abf0c 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -25,6 +25,7 @@ import type { GenericQuoteRequest, QuoteMetadata, QuoteResponse, + TokenAmountValues, } from './types'; import { RequestStatus, SortOrder } from './types'; import { @@ -322,21 +323,21 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( createBridgeSelector( [ (state) => state, - ({ quoteRequest: { srcChainId } }) => srcChainId, - ({ quoteRequest: { srcTokenAddress } }) => srcTokenAddress, + ({ quoteRequest: [{ srcChainId }] }) => srcChainId, + ({ quoteRequest: [{ srcTokenAddress }] }) => srcTokenAddress, ], selectExchangeRateByChainIdAndAddress, ), createBridgeSelector( [ (state) => state, - ({ quoteRequest: { destChainId } }) => destChainId, - ({ quoteRequest: { destTokenAddress } }) => destTokenAddress, + ({ quoteRequest: [{ destChainId }] }) => destChainId, + ({ quoteRequest: [{ destTokenAddress }] }) => destTokenAddress, ], selectExchangeRateByChainIdAndAddress, ), createBridgeSelector( - [(state) => state, ({ quoteRequest: { srcChainId } }) => srcChainId], + [(state) => state, ({ quoteRequest: [{ srcChainId }] }) => srcChainId], (state, chainId) => selectExchangeRateByChainIdAndAddress(state, chainId, AddressZero), ), @@ -497,7 +498,7 @@ const selectActiveQuote = createBridgeSelector( const selectIsQuoteGoingToRefresh = createBridgeSelector( [ selectBridgeFeatureFlags, - (state) => state.quoteRequest.insufficientBal, + (state) => state.quoteRequest[0]?.insufficientBal, (state) => state.quotesRefreshCount, ], (featureFlags, insufficientBal, quotesRefreshCount) => @@ -505,7 +506,7 @@ const selectIsQuoteGoingToRefresh = createBridgeSelector( ); const selectQuoteRefreshRate = createBridgeSelector( - [selectBridgeFeatureFlags, (state) => state.quoteRequest.srcChainId], + [selectBridgeFeatureFlags, (state) => state.quoteRequest[0]?.srcChainId], (featureFlags, srcChainId) => (srcChainId ? featureFlags.chains[formatChainIdToCaip(srcChainId)]?.refreshRate @@ -522,8 +523,8 @@ export const selectIsQuoteExpired = createBridgeSelector( (isQuoteGoingToRefresh, quotesLastFetched, refreshRate, currentTimeInMs) => Boolean( !isQuoteGoingToRefresh && - quotesLastFetched && - currentTimeInMs - quotesLastFetched > refreshRate, + quotesLastFetched && + currentTimeInMs - quotesLastFetched > refreshRate, ), ); @@ -558,6 +559,83 @@ export const selectBridgeQuotes = createStructuredBridgeSelector({ isQuoteGoingToRefresh: selectIsQuoteGoingToRefresh, }); +const selectRecommendedQuotes = createBridgeSelector( + [ + selectSortedBridgeQuotes, + (_, { requestCount }: { requestCount: number }) => requestCount, + ], + (quotes, requestCount) => + quotes.reduce((acc, quote) => { + const requestIndex = quote.quoteRequestIndex ?? 0; + acc[requestIndex] ??= quote; + return acc; + }, Array<(QuoteResponse & QuoteMetadata) | null>(requestCount).fill(null)), +); + +const selectMetadataSum = createBridgeSelector( + [ + selectRecommendedQuotes, + ( + _, + { + key, + }: { key: 'totalNetworkFee' | 'minToTokenAmount' | 'toTokenAmount' }, + ) => key, + ], + (recommendedQuotes, key) => + recommendedQuotes.reduce( + (acc, quote) => { + acc.usd = new BigNumber(acc.usd ?? 0) + .plus(quote?.[key]?.usd ?? 0) + .toString(); + acc.valueInCurrency = new BigNumber(acc.valueInCurrency ?? 0) + .plus(quote?.[key]?.valueInCurrency ?? 0) + .toString(); + acc.amount = new BigNumber(acc.amount ?? 0) + .plus(quote?.[key]?.amount ?? 0) + .toString(); + return acc; + }, + { usd: null, valueInCurrency: null, amount: '0' }, + ), +); + +/** + * Selects the recommended quotes for a batch of quote requests. + * + * @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 recommendedQuotes, totalReceived, minimumReceived, totalNetworkFee, and other quote fetching metadata + * + * @example + * ```ts + * const quoteBatch = useSelector(state => selectBridgeQuotesBatch( + * { ...state.metamask }, + * { + * sortOrder: state.bridge.sortOrder, + * requestCount: 4, + * } + * )); + * ``` + */ +export const selectBridgeQuotesBatch = createStructuredBridgeSelector({ + recommendedQuotes: selectRecommendedQuotes, + totalReceived: (state, opts) => + 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, + quotesRefreshCount: (state) => state.quotesRefreshCount, + quotesInitialLoadTimeMs: (state) => state.quotesInitialLoadTime, + isQuoteGoingToRefresh: selectIsQuoteGoingToRefresh, +}); + export const selectMinimumBalanceForRentExemptionInSOL = ( state: BridgeAppState, ) => diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 5581bd6560..5f030bd45e 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -303,6 +303,11 @@ export type QuoteResponse< * If defined, the quote's total network fee will include the reset approval's gas limit. */ resetApproval?: TxData; + /** + * Appended to the quote if there are multiple quote requests in a batch. This + * indicates which quoteRequest the quote is for + */ + quoteRequestIndex?: number; }; export enum ChainId { @@ -358,7 +363,7 @@ export enum BridgeBackgroundAction { } export type BridgeControllerState = { - quoteRequest: Partial; + quoteRequest: Partial[]; quotes: (QuoteResponse & L1GasFees & NonEvmFees)[]; /** * The time elapsed between the initial quote fetch and when the first valid quote was received diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 686f1ee17e..859428d48d 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -13,6 +13,7 @@ import type { } from '../types'; import { getEthUsdtResetData } from './bridge'; import { + formatAddressToAssetId, formatAddressToCaipReference, formatChainIdToDec, } from './caip-formatters'; @@ -80,12 +81,12 @@ export async function fetchBridgeTokens( } /** - * Converts the generic quote request to the type that the bridge-api expects + * Converts the generic quote request to QuoteRequest * * @param request - The quote request - * @returns A URLSearchParams object with the query parameters + * @returns A QuoteRequest object */ -const formatQueryParams = (request: GenericQuoteRequest): URLSearchParams => { +const formatQuoteRequest = (request: GenericQuoteRequest): QuoteRequest => { const destWalletAddress = request.destWalletAddress ?? request.walletAddress; // Transform the generic quote request into QuoteRequest const normalizedRequest: QuoteRequest = { @@ -114,6 +115,18 @@ const formatQueryParams = (request: GenericQuoteRequest): URLSearchParams => { normalizedRequest.bridgeIds = request.bridgeIds; } + return normalizedRequest; +}; + +/** + * Converts the generic quote request to the type that the bridge-api expects + * + * @param normalizedRequest - The normalized quote request + * @returns A URLSearchParams object with the query parameters + */ +const formatQueryParams = ( + normalizedRequest: QuoteRequest, +): URLSearchParams => { const queryParams = new URLSearchParams(); Object.entries(normalizedRequest).forEach(([key, value]) => { queryParams.append(key, value.toString()); @@ -147,7 +160,8 @@ export async function fetchBridgeQuotes( quotes: QuoteResponse[]; validationFailures: string[]; }> { - const queryParams = formatQueryParams(request); + const normalizedRequest = formatQuoteRequest(request); + const queryParams = formatQueryParams(normalizedRequest); const url = `${bridgeApiBaseUrl}/getQuote?${queryParams}`; const quotes: unknown[] = await fetchFn(url, { @@ -288,12 +302,26 @@ export const fetchAssetPrices = async ( return combinedPrices; }; +const getQuoteRequestId = ({ + srcChainId, + destChainId, + srcTokenAddress, + destTokenAddress, +}: QuoteRequest): string => { + return `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`.toLowerCase(); +}; +const getQuoteResponseId = ({ + srcAsset: { address: srcTokenAddress, chainId: srcChainId }, + destAsset: { address: destTokenAddress, chainId: destChainId }, +}: QuoteResponse['quote']): string => { + return `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`.toLowerCase(); +}; + /** - * Converts the generic quote request to the type that the bridge-api expects - * then fetches quotes from the bridge-api + * Fetches quotes from the bridge-api * * @param fetchFn - The fetch function to use - * @param request - The quote request + * @param quoteRequests - An array of GenericQuoteRequest objects * @param signal - The abort signal * @param clientId - The client ID for metrics * @param jwt - The JWT token for authentication @@ -309,7 +337,7 @@ export const fetchAssetPrices = async ( */ export async function fetchBridgeQuoteStream( fetchFn: FetchFunction, - request: GenericQuoteRequest, + quoteRequests: GenericQuoteRequest[], signal: AbortSignal | undefined, clientId: string, jwt: string | undefined, @@ -323,25 +351,42 @@ export async function fetchBridgeQuoteStream( }, clientVersion?: string, ): Promise { - const queryParams = formatQueryParams(request); + const isBatchRequest = quoteRequests.length > 1; + const normalizedQuoteRequests = quoteRequests.map(formatQuoteRequest); + const quoteRequestIds = isBatchRequest + ? normalizedQuoteRequests.map(getQuoteRequestId) + : undefined; const onQuoteReceived = async (quoteResponse: unknown): Promise => { const uniqueValidationFailures: Set = new Set([]); try { if (validateQuoteResponse(quoteResponse)) { + // Fallback to 0 if the quote doesn't match any requests + const matchedQuoteRequestIdx = Math.max( + quoteRequestIds?.findIndex((id) => { + return id === getQuoteResponseId(quoteResponse.quote); + }) ?? 0, + 0, + ); + const matchingQuoteRequest = + normalizedQuoteRequests[matchedQuoteRequestIdx]; + return await serverEventHandlers.onValidQuoteReceived({ ...quoteResponse, // Append the reset approval data to the quote response if the request has resetApproval set to true and the quote has an approval resetApproval: - request.resetApproval && + matchingQuoteRequest.resetApproval && quoteResponse.approval && isEvmTxData(quoteResponse.approval) ? { ...quoteResponse.approval, - data: getEthUsdtResetData(request.destChainId), + data: getEthUsdtResetData(matchingQuoteRequest.destChainId), } : undefined, + ...(isBatchRequest && { + quoteRequestIndex: matchedQuoteRequestIdx, + }), }); } } catch (error) { @@ -404,6 +449,30 @@ export async function fetchBridgeQuoteStream( } }; + if (quoteRequests.length > 1) { + const urlStream = `${bridgeApiBaseUrl}/getBatchQuoteStream`; + await fetchServerEvents(urlStream, { + method: 'POST', + body: JSON.stringify({ requests: normalizedQuoteRequests }), + headers: { + ...getClientHeaders({ clientId, clientVersion, jwt }), + 'Content-Type': 'application/json', + }, + signal, + onMessage, + onError: (error) => { + // Rethrow error to prevent silent fetch failures + throw error; + }, + onClose: async () => { + await serverEventHandlers.onClose(); + }, + fetchFn, + }); + return; + } + + const queryParams = formatQueryParams(normalizedQuoteRequests[0]); const urlStream = `${bridgeApiBaseUrl}/getQuoteStream?${queryParams}`; await fetchServerEvents(urlStream, { headers: { diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index a5db735ad1..49315adf8f 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -151,7 +151,7 @@ export const isHardwareWallet = ( * @deprecated This function should not be used. Use {@link selectDefaultSlippagePercentage} instead. */ export const isCustomSlippage = (slippage: GenericQuoteRequest['slippage']) => { - return slippage !== DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest.slippage; + return slippage !== DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest[0]?.slippage; }; export const getQuotesReceivedProperties = ( diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index b2004777a9..0cb2f8ea1a 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -83,6 +83,12 @@ export const isValidQuoteRequest = ( ); }; +export const isValidQuoteRequestBatch = ( + quoteRequests: Partial[], + requireAmount = true, +): quoteRequests is GenericQuoteRequest[] => + quoteRequests.every((req) => isValidQuoteRequest(req, requireAmount)); + /** * Generates a pseudo-unique string that identifies each quote by aggregator, bridge, and steps * diff --git a/packages/bridge-controller/tests/mock-sse.ts b/packages/bridge-controller/tests/mock-sse.ts index ecf7690e3d..40a86c3fe4 100644 --- a/packages/bridge-controller/tests/mock-sse.ts +++ b/packages/bridge-controller/tests/mock-sse.ts @@ -70,6 +70,36 @@ export const mockSseEventSource = ( }; }; +/** + * This simulates responses from the fetch function for unit tests + * + * @param mockQuotes - a list of quotes to stream + * @param delay - the delay in milliseconds + * @returns a delayed stream of quotes + */ +export const mockSseBatchEventSource = ( + mockQuotes: QuoteResponse[][], + delay: number = 3000, +): MockSseResponse => { + return { + status: 200, + ok: true, + body: new ReadableStream({ + start(controller): void { + setTimeout(() => { + mockQuotes.forEach((quotes) => { + quotes.forEach((quote, quoteIndex) => { + emitLine(controller, `event: quote\n`); + emitLine(controller, `id: ${getEventId(quoteIndex + 1)}\n`); + emitLine(controller, `data: ${JSON.stringify(quote)}\n\n`); + }); + }); + controller.close(); + }, delay); + }, + }), + }; +}; /** * This simulates responses from the fetch function for unit tests *