diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index d6bb560cea8..73a20994ffc 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Introduce server‑sent events quote streaming and integrates incremental quote updates into the bridge controller polling flow ([#6760](https://github.com/MetaMask/core/pull/6760)) + - Add private `getQuoteStreaming` handler that calls `getQuoteStream` when the `sseEnabled` flag is enabled in LaunchDarkly + - Reuse existing polling, metrics and validation utilities when processing server-sent quotes +- Add dependency on `@microsoft/fetch-event-source` at `^2.0.1` ([#6760](https://github.com/MetaMask/core/pull/6760)) + - Note that clients need to patch this library such that it rejects instead of resolving when the quote request is cancelled. This preserves the controller's expected request cancellation behavior + +### Changed + +- Extract some logic from bridge-controller and move them to utility files for better readability ([#6760](https://github.com/MetaMask/core/pull/6760)) + +### Removed + +- Remove cache options from spot-prices and getQuote api calls since they are only required by the extension client ([#6760](https://github.com/MetaMask/core/pull/6760)) + +### Fixed + +- Pass abortSignal to fetchAssetPricesForCurrency in order to cancel exchange rate fetching when quote parameters change ([#6760](https://github.com/MetaMask/core/pull/6760)) + ## [50.0.0] ### Changed diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 2e11e8b0ef4..963575f6d7e 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -60,6 +60,7 @@ "@metamask/multichain-network-controller": "^1.0.1", "@metamask/polling-controller": "^14.0.1", "@metamask/utils": "^11.8.1", + "@microsoft/fetch-event-source": "^2.0.1", "bignumber.js": "^9.1.2", "reselect": "^5.1.1", "uuid": "^8.3.2" 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 new file mode 100644 index 00000000000..2117a786781 --- /dev/null +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap @@ -0,0 +1,224 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BridgeController SSE should publish validation failures 3`] = ` +Array [ + Array [ + "Unified SwapBridge Quotes Failed Validation", + Object { + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "failures": Array [ + "lifi|trade", + "lifi|trade.chainId", + "lifi|trade.to", + "lifi|trade.from", + "lifi|trade.value", + "lifi|trade.data", + "lifi|trade.gasLimit", + "lifi|trade.unsignedPsbtBase64", + "lifi|trade.inputsToSign", + ], + "refresh_count": 1, + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + }, + ], + Array [ + "Unified SwapBridge Quotes Failed Validation", + Object { + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "failures": Array [ + "unknown|quote", + ], + "refresh_count": 2, + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + }, + ], +] +`; + +exports[`BridgeController SSE should replace all stale quotes after a refresh and first quote is received 1`] = ` +Array [ + "Unified SwapBridge Quotes Requested", + Object { + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "has_sufficient_funds": true, + "is_hardware_wallet": false, + "security_warnings": Array [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": Array [], + }, +] +`; + +exports[`BridgeController SSE should reset and refetch quotes after quote request is changed 1`] = ` +Array [ + Array [ + "Unified SwapBridge Quotes Requested", + Object { + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "has_sufficient_funds": true, + "is_hardware_wallet": false, + "security_warnings": Array [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + }, + ], +] +`; + +exports[`BridgeController SSE should reset quotes list if quote refresh fails 2`] = ` +Array [ + Array [ + "Unified SwapBridge Quotes Requested", + Object { + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "has_sufficient_funds": true, + "is_hardware_wallet": false, + "security_warnings": Array [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": Array [], + }, + ], + Array [ + "Unified SwapBridge Quotes Error", + Object { + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "error_message": "Network error", + "has_sufficient_funds": true, + "is_hardware_wallet": false, + "security_warnings": Array [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": Array [], + }, + ], +] +`; + +exports[`BridgeController SSE should trigger quote polling if request is valid 1`] = ` +Object { + "assetExchangeRates": Object { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": Object { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "1000000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + "quotes": Array [], + "quotesInitialLoadTime": null, + "quotesLoadingStatus": 0, + "quotesRefreshCount": 0, +} +`; + +exports[`BridgeController SSE should trigger quote polling if request is valid 2`] = ` +Array [ + Array [ + "Unified SwapBridge Input Changed", + Object { + "action_type": "swapbridge-v1", + "input": "chain_source", + "input_value": "eip155:1", + }, + ], + Array [ + "Unified SwapBridge Input Changed", + Object { + "action_type": "swapbridge-v1", + "input": "chain_destination", + "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + }, + ], + Array [ + "Unified SwapBridge Input Changed", + Object { + "action_type": "swapbridge-v1", + "input": "token_destination", + "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + }, + ], + Array [ + "Unified SwapBridge Input Changed", + Object { + "action_type": "swapbridge-v1", + "input": "slippage", + "input_value": 0.5, + }, + ], + Array [ + "Unified SwapBridge Quotes Requested", + Object { + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "has_sufficient_funds": true, + "is_hardware_wallet": false, + "security_warnings": Array [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": Array [], + }, + ], +] +`; diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts new file mode 100644 index 00000000000..20c4773ab90 --- /dev/null +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -0,0 +1,555 @@ +import { SolScope } from '@metamask/keyring-api'; + +import { BridgeController } from './bridge-controller'; +import { + BridgeClientId, + BRIDGE_PROD_API_BASE_URL, + DEFAULT_BRIDGE_CONTROLLER_STATE, +} from './constants/bridge'; +import type { QuoteResponse } from './types'; +import { + ChainId, + RequestStatus, + type BridgeControllerMessenger, +} from './types'; +import * as balanceUtils from './utils/balance'; +import * as featureFlagUtils from './utils/feature-flags'; +import * as fetchUtils from './utils/fetch'; +import { flushPromises } from '../../../tests/helpers'; +import { handleFetch } from '../../controller-utils/src'; +import mockBridgeQuotesNativeErc20Eth from '../tests/mock-quotes-native-erc20-eth.json'; +import mockBridgeQuotesNativeErc20 from '../tests/mock-quotes-native-erc20.json'; +import { + advanceToNthTimer, + advanceToNthTimerThenFlush, + mockSseEventSource, +} from '../tests/mock-sse'; + +const quoteRequest = { + srcChainId: '0x1', + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '123d1', + srcTokenAmount: '1000000000000000000', + slippage: 0.5, + walletAddress: '0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294', + destWalletAddress: 'SolanaWalletAddres1234', +}; +const metricsContext = { + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + stx_enabled: true, + security_warnings: [], + warnings: [], +}; + +const assetExchangeRates = { + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + exchangeRate: undefined, + usdExchangeRate: '100', + }, +}; + +describe('BridgeController SSE', function () { + let bridgeController: BridgeController, + fetchAssetPricesSpy: jest.SpyInstance, + stopAllPollingSpy: jest.SpyInstance, + startPollingSpy: jest.SpyInstance, + hasSufficientBalanceSpy: jest.SpyInstance, + fetchBridgeQuotesSpy: jest.SpyInstance, + consoleLogSpy: jest.SpyInstance; + + const messengerMock = { + call: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + publish: jest.fn(), + } as unknown as jest.Mocked; + const getLayer1GasFeeMock = jest.fn(); + const mockFetchFn = handleFetch; + const trackMetaMetricsFn = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.resetAllMocks(); + + fetchAssetPricesSpy = jest + .spyOn(fetchUtils, 'fetchAssetPrices') + .mockResolvedValue({ + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + usd: '100', + }, + }); + getLayer1GasFeeMock.mockResolvedValue('0x1'); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + selectedNetworkClientId: 'selectedNetworkClientId', + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never); + jest.spyOn(featureFlagUtils, 'getBridgeFeatureFlags').mockReturnValue({ + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + sseEnabled: true, + 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, + }, + }, + }); + + bridgeController = new BridgeController({ + messenger: messengerMock, + getLayer1GasFee: getLayer1GasFeeMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + trackMetaMetricsFn, + clientVersion: '1.0.0', + }); + + mockSseEventSource( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + mockBridgeQuotesNativeErc20Eth as QuoteResponse[], + ); + + jest.useFakeTimers(); + stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuoteStream'); + consoleLogSpy = jest.spyOn(console, 'log'); + stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + }); + + it('should trigger quote polling if request is valid', async function () { + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + networkClientId: 'selectedNetworkClientId', + updatedQuoteRequest: { + ...quoteRequest, + insufficientBal: false, + }, + context: metricsContext, + }); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest, + assetExchangeRates, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + { + ...quoteRequest, + insufficientBal: false, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + BRIDGE_PROD_API_BASE_URL, + { + onValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onClose: expect.any(Function), + }, + '1.0.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(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: { ...quoteRequest, insufficientBal: false }, + quotes: mockBridgeQuotesNativeErc20.map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + })), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + + it('should replace all stale quotes after a refresh and first quote is received', async function () { + // 1st fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(bridgeController.state.quotes).toStrictEqual( + mockBridgeQuotesNativeErc20.map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + })), + ); + const t1 = bridgeController.state.quotesLastFetched; + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesInitialLoadTime: 5000, + quoteRequest: { ...quoteRequest, insufficientBal: false }, + quotes: [mockBridgeQuotesNativeErc20Eth[0]], + quotesLoadingStatus: RequestStatus.LOADING, + quotesRefreshCount: 2, + assetExchangeRates, + }; + + // 2nd fetch request's first server event + await advanceToNthTimerThenFlush(3); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesLastFetched: expect.any(Number), + }); + const t2 = bridgeController.state.quotesLastFetched; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(t2).toBeGreaterThan(t1!); + + // After 2nd server event + await advanceToNthTimerThenFlush(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotes: mockBridgeQuotesNativeErc20Eth, + quotesLastFetched: t2, + quotesRefreshCount: 2, + quotesLoadingStatus: RequestStatus.FETCHED, + }); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should reset quotes list if quote refresh fails', async function () { + consoleLogSpy.mockImplementationOnce(jest.fn()); + // 1st fetch + jest.advanceTimersByTime(25000); + await flushPromises(); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state.quotesInitialLoadTime).toBe(25000); + + // 2nd fetch + await advanceToNthTimerThenFlush(); + await advanceToNthTimerThenFlush(2); + expect(bridgeController.state.quotesRefreshCount).toBe(2); + expect(bridgeController.state.quotesInitialLoadTime).toBe(25000); + expect(bridgeController.state.quotes).toStrictEqual( + mockBridgeQuotesNativeErc20Eth, + ); + const t2 = bridgeController.state.quotesLastFetched; + + // 3nd fetch throws an error + await advanceToNthTimerThenFlush(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesInitialLoadTime: 25000, + quoteRequest: { ...quoteRequest, insufficientBal: false }, + quotes: [], + quotesLoadingStatus: 2, + quoteFetchError: 'Network error', + quotesRefreshCount: 3, + quotesLastFetched: Date.now(), + assetExchangeRates, + }); + expect( + bridgeController.state.quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBeGreaterThan(t2!); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "Failed to stream bridge quotes", + [Error: Network error], + ], + ] + `); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(8); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.slice(6, 8)).toMatchSnapshot(); + }); + + it('should reset and refetch quotes after quote request is changed', async function () { + consoleLogSpy.mockImplementationOnce(jest.fn()); + hasSufficientBalanceSpy.mockRejectedValue(new Error('Balance error')); + // 1st fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + + // 2nd fetch + await advanceToNthTimerThenFlush(); + expect(bridgeController.state.quotesRefreshCount).toBe(2); + await advanceToNthTimerThenFlush(2); + expect(bridgeController.state.quotesRefreshCount).toBe(2); + const t2 = bridgeController.state.quotesLastFetched; + + // 3nd fetch throws an error + await advanceToNthTimerThenFlush(); + const t5 = bridgeController.state.quotesLastFetched; + expect(bridgeController.state.quotesRefreshCount).toBe(3); + expect(bridgeController.state.quotes).toStrictEqual([]); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(t5).toBeGreaterThan(t2!); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: { + ...quoteRequest, + srcTokenAmount: '10', + }, + assetExchangeRates, + }; + // Start new quote request + await bridgeController.updateBridgeQuoteRequestParams( + { ...quoteRequest, srcTokenAmount: '10' }, + { + stx_enabled: true, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + security_warnings: [], + }, + ); + // Right after state update, before fetch has started + expect(bridgeController.state).toStrictEqual(expectedState); + advanceToNthTimer(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quoteRequest: { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: true, + }, + quotesLastFetched: Date.now(), + quotesLoadingStatus: RequestStatus.LOADING, + }); + const t1 = bridgeController.state.quotesLastFetched; + advanceToNthTimer(1); + // 1st quote is received + await advanceToNthTimerThenFlush(); + const expectedStateAfterFirstQuote = { + ...expectedState, + quotesInitialLoadTime: 2000, + quotes: [{ ...mockBridgeQuotesNativeErc20[0], l1GasFeesInHexWei: '0x1' }], + quotesRefreshCount: 1, + quotesLoadingStatus: RequestStatus.LOADING, + quoteRequest: { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: true, + }, + quotesLastFetched: t1, + }; + expect(bridgeController.state.quotes).toHaveLength(1); + expect(bridgeController.state).toStrictEqual({ + ...expectedStateAfterFirstQuote, + }); + const t4 = bridgeController.state.quotesLastFetched; + expect(t4).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t5!, + ); + // 2nd quote is received + await advanceToNthTimerThenFlush(3); + expect(bridgeController.state).toStrictEqual({ + ...expectedStateAfterFirstQuote, + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [ + ...mockBridgeQuotesNativeErc20, + ...mockBridgeQuotesNativeErc20, + ].map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + })), + }); + expect( + bridgeController.state.quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBe(t4!); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(2); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(9); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.slice(8, 9)).toMatchSnapshot(); + }); + + it('should publish validation failures', async function () { + consoleLogSpy.mockImplementationOnce(jest.fn()); + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementationOnce(jest.fn()) + .mockImplementationOnce(jest.fn()); + // 1st fetch + jest.advanceTimersByTime(9000); + await flushPromises(); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + + // 2nd fetch + await advanceToNthTimerThenFlush(); + await advanceToNthTimerThenFlush(2); + expect(bridgeController.state.quotesRefreshCount).toBe(2); + + // 3nd fetch throws an error + await advanceToNthTimerThenFlush(); + const t5 = bridgeController.state.quotesLastFetched; + expect(bridgeController.state.quotesRefreshCount).toBe(3); + expect(bridgeController.state.quotes).toStrictEqual([]); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + + // Start new quote request + await bridgeController.updateBridgeQuoteRequestParams( + { ...quoteRequest, srcTokenAmount: '10' }, + { + stx_enabled: true, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + security_warnings: [], + }, + ); + + // 1st quote is received + await advanceToNthTimerThenFlush(3); + const t4 = bridgeController.state.quotesLastFetched; + expect(t4).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t5!, + ); + expect(bridgeController.state.quotesRefreshCount).toBe(1); + // 2nd quote is received + await advanceToNthTimerThenFlush(3); + expect(bridgeController.state.quotes).toStrictEqual( + [...mockBridgeQuotesNativeErc20, ...mockBridgeQuotesNativeErc20].map( + (quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + }), + ), + ); + + // 2nd fetch after request is updated + // Iterate through a list of received valid and invalid quotes + // Invalid quotes received + advanceToNthTimer(2); + // Invalid quote + await advanceToNthTimerThenFlush(); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesInitialLoadTime: 2000, + quoteRequest: { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: false, + }, + quotes: [mockBridgeQuotesNativeErc20Eth[0]], + quotesRefreshCount: 2, + quoteFetchError: null, + quotesLoadingStatus: RequestStatus.LOADING, + assetExchangeRates, + quotesLastFetched: expect.any(Number), + }; + const t6 = bridgeController.state.quotesLastFetched; + expect(t6).toBeCloseTo(Date.now() - 2000); + // Empty event.data + await advanceToNthTimerThenFlush(); + // Valid quote + await advanceToNthTimerThenFlush(); + expect(bridgeController.state).toStrictEqual(expectedState); + const t7 = bridgeController.state.quotesLastFetched; + expect(t7).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t6!, + ); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Quote validation failed", + Array [ + "lifi|trade", + "lifi|trade.chainId", + "lifi|trade.to", + "lifi|trade.from", + "lifi|trade.value", + "lifi|trade.data", + "lifi|trade.gasLimit", + "lifi|trade.unsignedPsbtBase64", + "lifi|trade.inputsToSign", + ], + ] + `); + // Invalid quote + await advanceToNthTimerThenFlush(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesRefreshCount: 2, + quotesLoadingStatus: RequestStatus.FETCHED, + }); + expect(bridgeController.state.quotesLastFetched).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t7!, + ); + expect(consoleWarnSpy.mock.calls).toHaveLength(2); + expect(consoleWarnSpy.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + "Quote validation failed", + Array [ + "unknown|quote", + ], + ] + `); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(5); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(2); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(12); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.slice(10, 12)).toMatchSnapshot(); + }); +}); diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index d9033ad81b0..3ead2e8f47d 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -431,7 +431,9 @@ describe('BridgeController', function () { null, '1.0.0', ); - expect(bridgeController.state.quotesLastFetched).toBeNull(); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 1000, + ); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ @@ -708,7 +710,7 @@ describe('BridgeController', function () { quoteFetchError: null, assetExchangeRates: {}, quotesRefreshCount: 1, - quotesInitialLoadTime: expect.any(Number), + quotesInitialLoadTime: 2900, quotesLastFetched: expect.any(Number), }), ); @@ -934,13 +936,17 @@ describe('BridgeController', function () { null, '1.0.0', ); - expect(bridgeController.state.quotesLastFetched).toBeNull(); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 1000, + ); + const t1 = bridgeController.state.quotesLastFetched; expect(bridgeController.state).toStrictEqual( expect.objectContaining({ quoteRequest: { ...quoteRequest, insufficientBal: true }, quotes: [], quotesLoadingStatus: 0, + quotesLastFetched: t1, }), ); @@ -990,6 +996,7 @@ describe('BridgeController', function () { }), ); const secondFetchTime = bridgeController.state.quotesLastFetched; + expect(secondFetchTime).toStrictEqual(t1); expect(secondFetchTime).toStrictEqual(firstFetchTime); expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); }); @@ -1400,7 +1407,9 @@ describe('BridgeController', function () { null, '1.0.0', ); - expect(bridgeController.state.quotesLastFetched).toBeNull(); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 500, + ); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ @@ -1532,7 +1541,7 @@ describe('BridgeController', function () { expect(stateWithoutQuotes).toMatchSnapshot(); expect(quotes).toStrictEqual(mockBridgeQuotesNativeErc20Eth); - expect(quotesLastFetched).toBeCloseTo(Date.now()); + expect(quotesLastFetched).toBeCloseTo(Date.now() - 10000); jest.advanceTimersByTime(10000); await flushPromises(); @@ -1608,7 +1617,9 @@ describe('BridgeController', function () { expect(bridgeController.state.quotesLoadingStatus).toBe( RequestStatus.LOADING, ); - expect(bridgeController.state.quotesLastFetched).toBeNull(); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 1000, + ); expect(bridgeController.state.quotesRefreshCount).toBe(0); expect(bridgeController.state.quotes).toStrictEqual([]); }); @@ -2569,6 +2580,40 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual(expectedControllerState); }); + it('should throw error if account is not found', async () => { + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: quotesByDecreasingProcessingTime as never, + validationFailures: [], + }); + const expectedControllerState = bridgeController.state; + + await expect( + bridgeController.fetchQuotes( + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + // walletAddress: '0x123', + slippage: 0.5, + aggIds: ['other'], + bridgeIds: ['other', 'debridge'], + gasIncluded: false, + gasIncluded7702: false, + noFee: false, + } as never, + null, + FeatureId.PERPS, + ), + ).rejects.toThrow('Account address is required'); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual(expectedControllerState); + }); + it('should add aggIds and noFee to perps request', async () => { const fetchBridgeQuotesSpy = jest .spyOn(fetchUtils, 'fetchBridgeQuotes') diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 27efdf3f3c7..d4be29a8716 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -3,22 +3,21 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { StateMetadata } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { NetworkClientId } from '@metamask/network-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { TransactionController } from '@metamask/transaction-controller'; -import type { CaipAssetType } from '@metamask/utils'; -import { numberToHex, type Hex } from '@metamask/utils'; +import type { CaipAssetType, Hex } from '@metamask/utils'; +import type { BridgeClientId } from './constants/bridge'; import { - type BridgeClientId, BRIDGE_CONTROLLER_NAME, BRIDGE_PROD_API_BASE_URL, DEFAULT_BRIDGE_CONTROLLER_STATE, METABRIDGE_CHAIN_TO_ADDRESS_MAP, REFRESH_INTERVAL_MS, } from './constants/bridge'; -import { CHAIN_IDS } from './constants/chains'; import { TraceName } from './constants/traces'; import { selectIsAssetExchangeRateInState } from './selectors'; import type { QuoteRequest } from './types'; @@ -27,7 +26,6 @@ import { type GenericQuoteRequest, type NonEvmFees, type QuoteResponse, - type TxData, type BridgeControllerState, type BridgeControllerMessenger, type FetchFunction, @@ -40,7 +38,6 @@ import { isCrossChain, isNonEvmChainId, isSolanaChainId, - sumHexes, } from './utils/bridge'; import { formatAddressToCaipReference, @@ -48,7 +45,11 @@ import { formatChainIdToHex, } from './utils/caip-formatters'; import { getBridgeFeatureFlags } from './utils/feature-flags'; -import { fetchAssetPrices, fetchBridgeQuotes } from './utils/fetch'; +import { + fetchAssetPrices, + fetchBridgeQuotes, + fetchBridgeQuoteStream, +} from './utils/fetch'; import { AbortReason, MetricsActionType, @@ -70,12 +71,10 @@ import type { RequiredEventContextFromClient, } from './utils/metrics/types'; import { type CrossChainSwapsEventProperties } from './utils/metrics/types'; -import { isValidQuoteRequest } from './utils/quote'; -import { - computeFeeRequest, - getMinimumBalanceForRentExemptionRequest, -} from './utils/snaps'; -import { FeatureId } from './utils/validators'; +import { isValidQuoteRequest, sortQuotes } from './utils/quote'; +import { appendFeesToQuotes } from './utils/quote-fees'; +import { getMinimumBalanceForRentExemptionInLamports } from './utils/snaps'; +import type { FeatureId } from './utils/validators'; const metadata: StateMetadata = { quoteRequest: { @@ -164,7 +163,7 @@ export class BridgeController extends StaticIntervalPollingController { - this.stopAllPolling(); - this.#abortController?.abort(AbortReason.QuoteRequestUpdated); - this.#trackInputChangedEvents(paramsToUpdate); - + this.resetState(AbortReason.QuoteRequestUpdated); const updatedQuoteRequest = { ...DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest, ...paramsToUpdate, }; - this.update((state) => { state.quoteRequest = updatedQuoteRequest; - state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; - state.quotesLastFetched = - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched; - state.quotesLoadingStatus = - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus; - state.quoteFetchError = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteFetchError; - state.quotesRefreshCount = - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount; - state.quotesInitialLoadTime = - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesInitialLoadTime; - // Reset required minimum balance if the source chain is not Solana - if ( - updatedQuoteRequest.srcChainId && - !isSolanaChainId(updatedQuoteRequest.srcChainId) - ) { - state.minimumBalanceForRentExemptionInLamports = - DEFAULT_BRIDGE_CONTROLLER_STATE.minimumBalanceForRentExemptionInLamports; - } }); await this.#fetchAssetExchangeRates(updatedQuoteRequest).catch((error) => @@ -326,10 +303,15 @@ export class BridgeController extends StaticIntervalPollingController { - return ( - a.estimatedProcessingTimeInSeconds - - b.estimatedProcessingTimeInSeconds - ); - }); - } - return quotesWithFees; + + return sortQuotes(quotesWithFees, featureId); }; readonly #trackResponseValidationFailures = ( @@ -482,6 +455,7 @@ export class BridgeController extends StaticIntervalPollingController { @@ -495,11 +469,6 @@ export class BridgeController extends StaticIntervalPollingController { - // Only check balance for EVM chains - if (isNonEvmChainId(quoteRequest.srcChainId)) { - return true; - } - const srcChainIdInHex = formatChainIdToHex(quoteRequest.srcChainId); const provider = this.#getSelectedNetworkClient()?.provider; const normalizedSrcTokenAddress = formatAddressToCaipReference( @@ -526,9 +495,8 @@ export class BridgeController extends StaticIntervalPollingController { - this.stopPollingForQuotes(AbortReason.ResetState); - + resetState = (reason = AbortReason.ResetState) => { + this.stopPollingForQuotes(reason); this.update((state) => { // Cannot do direct assignment to state, i.e. state = {... }, need to manually assign each field state.quoteRequest = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest; @@ -576,10 +544,17 @@ export class BridgeController extends StaticIntervalPollingController { - state.quotesLoadingStatus = RequestStatus.LOADING; state.quoteRequest = updatedQuoteRequest; state.quoteFetchError = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteFetchError; + state.quotesLastFetched = Date.now(); + state.quotesLoadingStatus = RequestStatus.LOADING; }); try { @@ -597,27 +572,49 @@ export class BridgeController extends StaticIntervalPollingController { + const selectedAccount = this.#getMultichainSelectedAccount( + updatedQuoteRequest.walletAddress, + ); // This call is not awaited to prevent blocking quote fetching if the snap takes too long to respond // eslint-disable-next-line @typescript-eslint/no-floating-promises this.#setMinimumBalanceForRentExemptionInLamports( updatedQuoteRequest.srcChainId, + selectedAccount.metadata?.snap?.id, ); + // Use SSE if enabled and return early + if (shouldStream) { + await this.#handleQuoteStreaming( + updatedQuoteRequest, + selectedAccount, + ); + return; + } + // Otherwise use regular fetch const quotes = await this.fetchQuotes( updatedQuoteRequest, - // AbortController is always defined by this line, because we assign it a few lines above, - // not sure why Jest thinks it's not - // Linters accurately say that it's defined - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.#abortController!.signal as AbortSignal, + this.#abortController?.signal, ); - this.update((state) => { + // Set the initial load time if this is the first fetch + if ( + state.quotesRefreshCount === + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount && + this.#quotesFirstFetched + ) { + state.quotesInitialLoadTime = + Date.now() - this.#quotesFirstFetched; + } state.quotes = quotes; state.quotesLoadingStatus = RequestStatus.FETCHED; }); }, ); } catch (error) { + // Reset the quotes list if the fetch fails to avoid showing stale quotes + this.update((state) => { + state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; + }); + // Ignore abort errors const isAbortError = (error as Error).name === 'AbortError'; if ( isAbortError || @@ -631,21 +628,36 @@ export class BridgeController extends StaticIntervalPollingController { - state.quoteFetchError = - error instanceof Error ? error.message : (error?.toString() ?? null); + // The error object reference is not guaranteed to exist on mobile so reading + // the message directly could cause an error. + let errorMessage; + try { + errorMessage = + (error as Error)?.message ?? (error as Error).toString(); + } catch { + // Intentionally empty + } finally { + state.quoteFetchError = errorMessage ?? 'Unknown error'; + } state.quotesLoadingStatus = RequestStatus.ERROR; - state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; }); + // Track event and log error this.trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.QuotesError, context, ); - console.log('Failed to fetch bridge quotes', error); + console.log( + `Failed to ${shouldStream ? 'stream' : 'fetch'} bridge quotes`, + error, + ); } - const bridgeFeatureFlags = getBridgeFeatureFlags(this.messagingSystem); - const { maxRefreshCount } = bridgeFeatureFlags; + // Update refresh count after fetching, validation and fee calculation have completed + this.update((state) => { + state.quotesRefreshCount += 1; + }); // Stop polling if the maximum number of refreshes has been reached if ( updatedQuoteRequest.insufficientBal || @@ -654,185 +666,84 @@ export class BridgeController extends StaticIntervalPollingController { - state.quotesInitialLoadTime = - state.quotesRefreshCount === 0 && this.#quotesFirstFetched - ? quotesLastFetched - this.#quotesFirstFetched - : this.state.quotesInitialLoadTime; - state.quotesLastFetched = quotesLastFetched; - state.quotesRefreshCount += 1; - }); }; - readonly #appendL1GasFees = async ( - quotes: QuoteResponse[], - ): Promise<(QuoteResponse & L1GasFees)[] | undefined> => { - // Indicates whether some of the quotes are not for optimism or base - const hasInvalidQuotes = quotes.some(({ quote }) => { - const chainId = formatChainIdToCaip(quote.srcChainId); - return ![CHAIN_IDS.OPTIMISM, CHAIN_IDS.BASE] - .map(formatChainIdToCaip) - .includes(chainId); - }); - - // Only append L1 gas fees if all quotes are for either optimism or base - if (hasInvalidQuotes) { - return undefined; - } - - const l1GasFeePromises = Promise.allSettled( - quotes.map(async (quoteResponse) => { - const { quote, trade, approval } = quoteResponse; - const chainId = numberToHex(quote.srcChainId); - - const getTxParams = (txData: TxData) => ({ - from: txData.from, - to: txData.to, - value: txData.value, - data: txData.data, - gasLimit: txData.gasLimit?.toString(), - }); - const approvalL1GasFees = approval - ? await this.#getLayer1GasFee({ - transactionParams: getTxParams(approval), - chainId, - }) - : '0x0'; - const tradeL1GasFees = await this.#getLayer1GasFee({ - transactionParams: getTxParams(trade), - chainId, - }); - - if (approvalL1GasFees === undefined || tradeL1GasFees === undefined) { - return undefined; - } + readonly #handleQuoteStreaming = async ( + updatedQuoteRequest: GenericQuoteRequest, + selectedAccount: InternalAccount, + ) => { + /** + * Tracks the number of valid quotes received from the current stream, which is used + * to determine when to clear the quotes list and set the initial load time + */ + let validQuotesCounter = 0; - return { - ...quoteResponse, - l1GasFeesInHexWei: sumHexes(approvalL1GasFees, tradeL1GasFees), - }; - }), + await fetchBridgeQuoteStream( + this.#fetchFn, + updatedQuoteRequest, + this.#abortController?.signal, + this.#clientId, + this.#config.customBridgeApiBaseUrl ?? BRIDGE_PROD_API_BASE_URL, + { + onValidationFailure: this.#trackResponseValidationFailures, + onValidQuoteReceived: async (quote: QuoteResponse) => { + const quotesWithFees = await appendFeesToQuotes( + [quote], + this.messagingSystem, + this.#getLayer1GasFee, + selectedAccount, + ); + if (quotesWithFees.length > 0) { + validQuotesCounter += 1; + } + this.update((state) => { + // Clear previous quotes and quotes load time when first quote in the current + // polling loop is received + // This enables clients to continue showing the previous quotes while new + // quotes are loading + // Note: If there are no valid quotes until the 2nd fetch, quotesInitialLoadTime will be > refreshRate + if (validQuotesCounter === 1) { + state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; + if (!state.quotesInitialLoadTime && this.#quotesFirstFetched) { + // Set the initial load time after the first quote is received + state.quotesInitialLoadTime = + Date.now() - this.#quotesFirstFetched; + } + } + state.quotes = [...state.quotes, ...quotesWithFees]; + }); + }, + onClose: () => { + this.update((state) => { + // If there are no valid quotes in the current stream, clear the quotes list + // to remove quotes from the previous stream + if (validQuotesCounter === 0) { + state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; + } + state.quotesLoadingStatus = RequestStatus.FETCHED; + }); + }, + }, + this.#clientVersion, ); - - const quotesWithL1GasFees = (await l1GasFeePromises).reduce< - (QuoteResponse & L1GasFees)[] - >((acc, result) => { - if (result.status === 'fulfilled' && result.value) { - acc.push(result.value); - } else if (result.status === 'rejected') { - console.error('Error calculating L1 gas fees for quote', result.reason); - } - return acc; - }, []); - - return quotesWithL1GasFees; }; - readonly #setMinimumBalanceForRentExemptionInLamports = ( + readonly #setMinimumBalanceForRentExemptionInLamports = async ( srcChainId: GenericQuoteRequest['srcChainId'], - ): Promise | undefined => { - const selectedAccount = this.#getMultichainSelectedAccount(); - - return isSolanaChainId(srcChainId) && selectedAccount?.metadata?.snap?.id - ? this.messagingSystem - .call( - 'SnapController:handleRequest', - getMinimumBalanceForRentExemptionRequest( - selectedAccount.metadata.snap?.id, - ), - ) // eslint-disable-next-line promise/always-return - .then((result) => { - this.update((state) => { - state.minimumBalanceForRentExemptionInLamports = String(result); - }); - }) - .catch((error) => { - console.error( - 'Error setting minimum balance for rent exemption', - error, - ); - this.update((state) => { - state.minimumBalanceForRentExemptionInLamports = - DEFAULT_BRIDGE_CONTROLLER_STATE.minimumBalanceForRentExemptionInLamports; - }); - }) - : undefined; - }; - - /** - * Appends transaction fees for non-EVM chains to quotes - * - * @param quotes - Array of quote responses to append fees to - * @param walletAddress - The wallet address for which the quotes were requested - * @returns Array of quotes with fees appended, or undefined if quotes are for EVM chains - */ - readonly #appendNonEvmFees = async ( - quotes: QuoteResponse[], - walletAddress: GenericQuoteRequest['walletAddress'], - ): Promise<(QuoteResponse & NonEvmFees)[] | undefined> => { - if ( - quotes.some(({ quote: { srcChainId } }) => !isNonEvmChainId(srcChainId)) - ) { - return undefined; + snapId?: string, + ) => { + if (!isSolanaChainId(srcChainId) || !snapId) { + return; } - - const selectedAccount = this.#getMultichainSelectedAccount(walletAddress); - const nonEvmFeePromises = Promise.allSettled( - quotes.map(async (quoteResponse) => { - const { trade, quote } = quoteResponse; - - if (selectedAccount?.metadata?.snap?.id && typeof trade === 'string') { - const scope = formatChainIdToCaip(quote.srcChainId); - - const response = (await this.messagingSystem.call( - 'SnapController:handleRequest', - computeFeeRequest( - selectedAccount.metadata.snap?.id, - trade, - selectedAccount.id, - scope, - ), - )) as { - type: 'base' | 'priority'; - asset: { - unit: string; - type: string; - amount: string; - fungible: true; - }; - }[]; - - const baseFee = response?.find((fee) => fee.type === 'base'); - // Store fees in native units as returned by the snap (e.g., SOL, BTC) - const feeInNative = baseFee?.asset?.amount || '0'; - - return { - ...quoteResponse, - nonEvmFeesInNative: feeInNative, - }; - } - return quoteResponse; - }), - ); - - const quotesWithNonEvmFees = (await nonEvmFeePromises).reduce< - (QuoteResponse & NonEvmFees)[] - >((acc, result) => { - if (result.status === 'fulfilled' && result.value) { - acc.push(result.value); - } else if (result.status === 'rejected') { - console.error( - 'Error calculating non-EVM fees for quote', - result.reason, - ); - } - return acc; - }, []); - - return quotesWithNonEvmFees; + const minimumBalanceForRentExemptionInLamports = + await getMinimumBalanceForRentExemptionInLamports( + snapId, + this.messagingSystem, + ); + this.update((state) => { + state.minimumBalanceForRentExemptionInLamports = + minimumBalanceForRentExemptionInLamports; + }); }; #getMultichainSelectedAccount( diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 3611295eaa0..6284ee1e35c 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -39,19 +39,9 @@ import type { TxDataSchema, } from './utils/validators'; -/** - * Additional options accepted by the extension's fetchWithCache function - */ -type FetchWithCacheOptions = { - cacheOptions?: { - cacheRefreshTime: number; - }; - functionName?: string; -}; - export type FetchFunction = ( - input: RequestInfo | URL, - init?: RequestInit & FetchWithCacheOptions, + input: RequestInfo | URL | string, + init?: RequestInit, // eslint-disable-next-line @typescript-eslint/no-explicit-any ) => Promise; @@ -305,10 +295,30 @@ export enum BridgeBackgroundAction { export type BridgeControllerState = { quoteRequest: Partial; quotes: (QuoteResponse & L1GasFees & NonEvmFees)[]; + /** + * The time elapsed between the initial quote fetch and when the first valid quote was received + */ quotesInitialLoadTime: number | null; + /** + * The timestamp of when the latest quote fetch started + */ quotesLastFetched: number | null; + /** + * The status of the quote fetch, including fee calculations and validations + * This is set to + * - LOADING when the quote fetch starts + * - FETCHED when the process completes successfully, including when quotes are empty + * - ERROR when any errors occur + * + * When SSE is enabled, this is set to LOADING even when a quote is available. It is only + * set to FETCHED when the stream is closed and all quotes have been received + */ quotesLoadingStatus: RequestStatus | null; quoteFetchError: string | null; + /** + * The number of times the quotes have been refreshed, starts at 0 and is + * incremented at the end of each quote fetch + */ quotesRefreshCount: number; /** * Asset exchange rates for EVM and multichain assets that are not indexed by the assets controllers diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 5e9d0061e87..9627e9bec82 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -87,10 +87,6 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getTokens?chainId=10', { - cacheOptions: { - cacheRefreshTime: 600000, - }, - functionName: 'fetchBridgeTokens', headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, }, ); @@ -183,10 +179,6 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5', { - cacheOptions: { - cacheRefreshTime: 0, - }, - functionName: 'fetchBridgeQuotes', headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, signal, }, @@ -245,10 +237,6 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5', { - cacheOptions: { - cacheRefreshTime: 0, - }, - functionName: 'fetchBridgeQuotes', headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, signal, }, @@ -325,10 +313,6 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5', { - cacheOptions: { - cacheRefreshTime: 0, - }, - functionName: 'fetchBridgeQuotes', headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, signal, }, @@ -400,10 +384,6 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5&noFee=true&aggIds=socket%2Clifi&bridgeIds=bridge1%2Cbridge2', { - cacheOptions: { - cacheRefreshTime: 0, - }, - functionName: 'fetchBridgeQuotes', headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, signal, }, @@ -470,16 +450,12 @@ describe('fetch', () => { 'https://price.api.cx.metamask.io/v3/spot-prices?assetIds=eip155%3A1%2Ferc20%3A0x123%2Ceip155%3A1%2Ferc20%3A0x456&vsCurrency=USD', { headers: { 'X-Client-Id': 'test', 'Client-Version': '1.0.0' }, - cacheOptions: { cacheRefreshTime: 30000 }, - functionName: 'fetchAssetExchangeRates', }, ); expect(mockFetchFn).toHaveBeenCalledWith( 'https://price.api.cx.metamask.io/v3/spot-prices?assetIds=eip155%3A1%2Ferc20%3A0x123%2Ceip155%3A1%2Ferc20%3A0x456&vsCurrency=EUR', { headers: { 'X-Client-Id': 'test', 'Client-Version': '1.0.0' }, - cacheOptions: { cacheRefreshTime: 30000 }, - functionName: 'fetchAssetExchangeRates', }, ); }); diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index bd93e1067c6..7f25b73feb4 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -1,6 +1,7 @@ import { StructError } from '@metamask/superstruct'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; -import { Duration } from '@metamask/utils'; +import type { EventSourceMessage } from '@microsoft/fetch-event-source'; +import { fetchEventSource } from '@microsoft/fetch-event-source'; import { isBitcoinChainId } from './bridge'; import { @@ -21,8 +22,6 @@ import type { BridgeAsset, } from '../types'; -const CACHE_REFRESH_TEN_MINUTES = 10 * Duration.Minute; - export const getClientHeaders = (clientId: string, clientVersion?: string) => ({ 'X-Client-Id': clientId, ...(clientVersion ? { 'Client-Version': clientVersion } : {}), @@ -53,8 +52,6 @@ export async function fetchBridgeTokens( // note that the Assets controller won't be able to provide tokens. In extension we fetch+cache the token list from bridge-api to handle this const tokens = await fetchFn(url, { headers: getClientHeaders(clientId, clientVersion), - cacheOptions: { cacheRefreshTime: CACHE_REFRESH_TEN_MINUTES }, - functionName: 'fetchBridgeTokens', }); const transformedTokens: Record = {}; @@ -68,29 +65,11 @@ export async function fetchBridgeTokens( /** * Converts the generic quote request to the type that the bridge-api expects - * then fetches quotes from the bridge-api * * @param request - The quote request - * @param signal - The abort signal - * @param clientId - The client ID for metrics - * @param fetchFn - The fetch function to use - * @param bridgeApiBaseUrl - The base URL for the bridge API - * @param featureId - The feature ID to append to each quote - * @param clientVersion - The client version for metrics (optional) - * @returns A list of bridge tx quotes + * @returns A URLSearchParams object with the query parameters */ -export async function fetchBridgeQuotes( - request: GenericQuoteRequest, - signal: AbortSignal | null, - clientId: string, - fetchFn: FetchFunction, - bridgeApiBaseUrl: string, - featureId: FeatureId | null, - clientVersion?: string, -): Promise<{ - quotes: QuoteResponse[]; - validationFailures: string[]; -}> { +const formatQueryParams = (request: GenericQuoteRequest): URLSearchParams => { const destWalletAddress = request.destWalletAddress ?? request.walletAddress; // Transform the generic quote request into QuoteRequest const normalizedRequest: QuoteRequest = { @@ -123,12 +102,39 @@ export async function fetchBridgeQuotes( Object.entries(normalizedRequest).forEach(([key, value]) => { queryParams.append(key, value.toString()); }); + return queryParams; +}; + +/** + * 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 fetchFn - The fetch function to use + * @param bridgeApiBaseUrl - The base URL for the bridge API + * @param featureId - The feature ID to append to each quote + * @param clientVersion - The client version for metrics (optional) + * @returns A list of bridge tx quotes + */ +export async function fetchBridgeQuotes( + request: GenericQuoteRequest, + signal: AbortSignal | null, + clientId: string, + fetchFn: FetchFunction, + bridgeApiBaseUrl: string, + featureId: FeatureId | null, + clientVersion?: string, +): Promise<{ + quotes: QuoteResponse[]; + validationFailures: string[]; +}> { + const queryParams = formatQueryParams(request); + const url = `${bridgeApiBaseUrl}/getQuote?${queryParams}`; const quotes: unknown[] = await fetchFn(url, { headers: getClientHeaders(clientId, clientVersion), signal, - cacheOptions: { cacheRefreshTime: 0 }, - functionName: 'fetchBridgeQuotes', }); const uniqueValidationFailures: Set = new Set([]); @@ -179,8 +185,10 @@ const fetchAssetPricesForCurrency = async (request: { clientId: string; clientVersion?: string; fetchFn: FetchFunction; + signal?: AbortSignal; }): Promise> => { - const { currency, assetIds, clientId, clientVersion, fetchFn } = request; + const { currency, assetIds, clientId, clientVersion, fetchFn, signal } = + request; const validAssetIds = Array.from(assetIds).filter(Boolean); if (validAssetIds.length === 0) { return {}; @@ -193,10 +201,8 @@ const fetchAssetPricesForCurrency = async (request: { const url = `https://price.api.cx.metamask.io/v3/spot-prices?${queryParams}`; const priceApiResponse = (await fetchFn(url, { headers: getClientHeaders(clientId, clientVersion), - cacheOptions: { cacheRefreshTime: Number(Duration.Second * 30) }, - functionName: 'fetchAssetExchangeRates', + signal, })) as Record; - if (!priceApiResponse || typeof priceApiResponse !== 'object') { return {}; } @@ -261,3 +267,92 @@ export const fetchAssetPrices = async ( return combinedPrices; }; + +/** + * Converts the generic quote request to the type that the bridge-api expects + * then fetches quotes from the bridge-api + * + * @param fetchFn - The fetch function to use + * @param request - The quote request + * @param signal - The abort signal + * @param clientId - The client ID for metrics + * @param bridgeApiBaseUrl - The base URL for the bridge API + * @param serverEventHandlers - The server event handlers + * @param serverEventHandlers.onValidationFailure - The function to handle validation failures + * @param serverEventHandlers.onValidQuoteReceived - The function to handle valid quotes + * @param serverEventHandlers.onClose - The function to run when the stream is closed and there are no thrown errors + * @param clientVersion - The client version for metrics (optional) + * @returns A list of bridge tx quotes + */ +export async function fetchBridgeQuoteStream( + fetchFn: FetchFunction, + request: GenericQuoteRequest, + signal: AbortSignal | undefined, + clientId: string, + bridgeApiBaseUrl: string, + serverEventHandlers: { + onClose: () => void; + onValidationFailure: (validationFailures: string[]) => void; + onValidQuoteReceived: (quotes: QuoteResponse) => Promise; + }, + clientVersion?: string, +): Promise { + const queryParams = formatQueryParams(request); + + const onMessage = (event: EventSourceMessage) => { + const uniqueValidationFailures: Set = new Set([]); + if (event.data === '') { + return; + } + const quoteResponse = JSON.parse(event.data); + + try { + validateQuoteResponse(quoteResponse); + // eslint-disable-next-line promise/catch-or-return, @typescript-eslint/no-floating-promises + serverEventHandlers.onValidQuoteReceived(quoteResponse).then((v) => { + return v; + }); + } catch (error) { + if (error instanceof StructError) { + error.failures().forEach(({ branch, path }) => { + const aggregatorId = + branch?.[0]?.quote?.bridgeId || + branch?.[0]?.quote?.bridges?.[0] || + (quoteResponse as QuoteResponse)?.quote?.bridgeId || + (quoteResponse as QuoteResponse)?.quote?.bridges?.[0] || + 'unknown'; + const pathString = path?.join('.') || 'unknown'; + uniqueValidationFailures.add([aggregatorId, pathString].join('|')); + }); + } + const validationFailures = Array.from(uniqueValidationFailures); + if (uniqueValidationFailures.size > 0) { + console.warn('Quote validation failed', validationFailures); + serverEventHandlers.onValidationFailure(validationFailures); + } else { + // Rethrow any unexpected errors + throw error; + } + } + }; + + const urlStream = `${bridgeApiBaseUrl}/getQuoteStream?${queryParams}`; + await fetchEventSource(urlStream, { + headers: { + ...getClientHeaders(clientId, clientVersion), + 'Content-Type': 'text/event-stream', + }, + signal, + onmessage: onMessage, + onerror: (e) => { + // Rethrow error to prevent silent fetch failures + throw new Error(e.toString()); + }, + onclose: () => { + serverEventHandlers.onClose(); + }, + // Cancels the request when document is hidden, will automatically restart when visible + openWhenHidden: false, + fetch: fetchFn, + }); +} diff --git a/packages/bridge-controller/src/utils/quote-fees.ts b/packages/bridge-controller/src/utils/quote-fees.ts new file mode 100644 index 00000000000..81645208f0f --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-fees.ts @@ -0,0 +1,183 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { TransactionController } from '@metamask/transaction-controller'; +import { numberToHex } from '@metamask/utils'; + +import { isNonEvmChainId, sumHexes } from './bridge'; +import { formatChainIdToCaip } from './caip-formatters'; +import { computeFeeRequest } from './snaps'; +import { CHAIN_IDS } from '../constants/chains'; +import type { + QuoteResponse, + L1GasFees, + NonEvmFees, + TxData, + BridgeControllerMessenger, +} from '../types'; + +/** + * Appends transaction fees for EVM chains to quotes + * + * @param quotes - Array of quote responses to append fees to + * @param getLayer1GasFee - The function to use to get the layer 1 gas fee + * @returns Array of quotes with fees appended, or undefined if quotes are for non-EVM chains + */ +const appendL1GasFees = async ( + quotes: QuoteResponse[], + getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee, +): Promise<(QuoteResponse & L1GasFees)[] | undefined> => { + // Indicates whether some of the quotes are not for optimism or base + const hasInvalidQuotes = quotes.some(({ quote }) => { + const chainId = formatChainIdToCaip(quote.srcChainId); + return ![CHAIN_IDS.OPTIMISM, CHAIN_IDS.BASE] + .map(formatChainIdToCaip) + .includes(chainId); + }); + + // Only append L1 gas fees if all quotes are for either optimism or base + if (hasInvalidQuotes) { + return undefined; + } + + const l1GasFeePromises = Promise.allSettled( + quotes.map(async (quoteResponse) => { + const { quote, trade, approval } = quoteResponse; + const chainId = numberToHex(quote.srcChainId); + + const getTxParams = (txData: TxData) => ({ + from: txData.from, + to: txData.to, + value: txData.value, + data: txData.data, + gasLimit: txData.gasLimit?.toString(), + }); + const approvalL1GasFees = approval + ? await getLayer1GasFee({ + transactionParams: getTxParams(approval), + chainId, + }) + : '0x0'; + const tradeL1GasFees = await getLayer1GasFee({ + transactionParams: getTxParams(trade), + chainId, + }); + + if (approvalL1GasFees === undefined || tradeL1GasFees === undefined) { + return undefined; + } + + return { + ...quoteResponse, + l1GasFeesInHexWei: sumHexes(approvalL1GasFees, tradeL1GasFees), + }; + }), + ); + + const quotesWithL1GasFees = (await l1GasFeePromises).reduce< + (QuoteResponse & L1GasFees)[] + >((acc, result) => { + if (result.status === 'fulfilled' && result.value) { + acc.push(result.value); + } else if (result.status === 'rejected') { + console.error('Error calculating L1 gas fees for quote', result.reason); + } + return acc; + }, []); + + return quotesWithL1GasFees; +}; + +/** + * Appends transaction fees for non-EVM chains to quotes + * + * @param quotes - Array of quote responses to append fees to + * @param messenger - The messaging system to use to call the snap controller + * @param selectedAccount - The selected account for which the quotes were requested + * @returns Array of quotes with fees appended, or undefined if quotes are for EVM chains + */ +const appendNonEvmFees = async ( + quotes: QuoteResponse[], + messenger: BridgeControllerMessenger, + selectedAccount: InternalAccount, +): Promise<(QuoteResponse & NonEvmFees)[] | undefined> => { + if ( + quotes.some(({ quote: { srcChainId } }) => !isNonEvmChainId(srcChainId)) + ) { + return undefined; + } + + const nonEvmFeePromises = Promise.allSettled( + quotes.map(async (quoteResponse) => { + const { trade, quote } = quoteResponse; + + if (selectedAccount?.metadata?.snap?.id && typeof trade === 'string') { + const scope = formatChainIdToCaip(quote.srcChainId); + + const response = (await messenger.call( + 'SnapController:handleRequest', + computeFeeRequest( + selectedAccount.metadata.snap?.id, + trade, + selectedAccount.id, + scope, + ), + )) as { + type: 'base' | 'priority'; + asset: { + unit: string; + type: string; + amount: string; + fungible: true; + }; + }[]; + + const baseFee = response?.find((fee) => fee.type === 'base'); + // Store fees in native units as returned by the snap (e.g., SOL, BTC) + const feeInNative = baseFee?.asset?.amount || '0'; + + return { + ...quoteResponse, + nonEvmFeesInNative: feeInNative, + }; + } + return quoteResponse; + }), + ); + + const quotesWithNonEvmFees = (await nonEvmFeePromises).reduce< + (QuoteResponse & NonEvmFees)[] + >((acc, result) => { + if (result.status === 'fulfilled' && result.value) { + acc.push(result.value); + } else if (result.status === 'rejected') { + console.error('Error calculating non-EVM fees for quote', result.reason); + } + return acc; + }, []); + + return quotesWithNonEvmFees; +}; + +/** + * Appends transaction fees to quotes + * + * @param quotes - Array of quote responses to append fees to + * @param messenger - The bridge controller to use to call the snap controller + * @param getLayer1GasFee - The function to use to get the layer 1 gas fee + * @param selectedAccount - The selected account for which the quotes were requested + * @returns Array of quotes with fees appended, or undefined if quotes are for EVM chains + */ +export const appendFeesToQuotes = async ( + quotes: QuoteResponse[], + messenger: BridgeControllerMessenger, + getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee, + selectedAccount: InternalAccount, +): Promise<(QuoteResponse & L1GasFees & NonEvmFees)[]> => { + const quotesWithL1GasFees = await appendL1GasFees(quotes, getLayer1GasFee); + const quotesWithNonEvmFees = await appendNonEvmFees( + quotes, + messenger, + selectedAccount, + ); + + return quotesWithL1GasFees ?? quotesWithNonEvmFees ?? quotes; +}; diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index a5284e45e8f..0e1979f5055 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -6,6 +6,7 @@ import { import { BigNumber } from 'bignumber.js'; import { isNativeAddress, isNonEvmChainId } from './bridge'; +import { FeatureId } from './validators'; import type { BridgeAsset, ExchangeRate, @@ -465,3 +466,18 @@ export const formatEtaInMinutes = ( } return (estimatedProcessingTimeInSeconds / 60).toFixed(); }; + +export const sortQuotes = ( + quotes: QuoteResponse[], + featureId: FeatureId | null, +) => { + // Sort perps quotes by increasing estimated processing time (fastest first) + if (featureId === FeatureId.PERPS) { + return quotes.sort((a, b) => { + return ( + a.estimatedProcessingTimeInSeconds - b.estimatedProcessingTimeInSeconds + ); + }); + } + return quotes; +}; diff --git a/packages/bridge-controller/src/utils/snaps.ts b/packages/bridge-controller/src/utils/snaps.ts index b81511f8fdd..3c0110da206 100644 --- a/packages/bridge-controller/src/utils/snaps.ts +++ b/packages/bridge-controller/src/utils/snaps.ts @@ -2,6 +2,9 @@ import { SolScope } from '@metamask/keyring-api'; import type { CaipChainId } from '@metamask/utils'; import { v4 as uuid } from 'uuid'; +import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../constants/bridge'; +import type { BridgeControllerMessenger } from '../types'; + export const getMinimumBalanceForRentExemptionRequest = (snapId: string) => { return { snapId: snapId as never, @@ -23,6 +26,34 @@ export const getMinimumBalanceForRentExemptionRequest = (snapId: string) => { }; }; +/** + * Gets the minimum balance for rent exemption in lamports for a given chain ID and selected account + * + * @param snapId - The snap ID to send the request to + * @param messenger - The messaging system to use to call the snap controller + * @returns The minimum balance for rent exemption in lamports + */ +export const getMinimumBalanceForRentExemptionInLamports = async ( + snapId: string, + messenger: BridgeControllerMessenger, +) => { + return String( + await messenger + .call( + 'SnapController:handleRequest', + getMinimumBalanceForRentExemptionRequest(snapId), + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .catch((error: any) => { + console.error( + 'Error setting minimum balance for rent exemption', + error, + ); + return DEFAULT_BRIDGE_CONTROLLER_STATE.minimumBalanceForRentExemptionInLamports; + }), + ); +}; + /** * Creates a request to compute fees for a transaction using the new unified interface * Returns fees in native token amount (e.g., Solana instead of Lamports) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index b5815478587..5d5507b6225 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -137,6 +137,7 @@ export const PlatformConfigSchema = type({ * Key is the CAIP chainId namespace */ bip44DefaultPairs: optional(record(string(), optional(DefaultPairSchema))), + sseEnabled: optional(boolean()), }); export const validateFeatureFlagsResponse = ( diff --git a/packages/bridge-controller/tests/mock-sse.ts b/packages/bridge-controller/tests/mock-sse.ts new file mode 100644 index 00000000000..5968955fe3e --- /dev/null +++ b/packages/bridge-controller/tests/mock-sse.ts @@ -0,0 +1,142 @@ +import * as eventSource from '@microsoft/fetch-event-source'; + +import { flushPromises } from '../../../tests/helpers'; +import type { QuoteResponse, TxData } from '../src'; + +export const advanceToNthTimer = (n = 1) => { + for (let i = 0; i < n; i++) { + jest.advanceTimersToNextTimer(); + } +}; + +export const advanceToNthTimerThenFlush = async (n = 1) => { + advanceToNthTimer(n); + await flushPromises(); +}; + +const mockOnOpen = (onOpen?: (response: Response) => Promise) => + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + onOpen?.({ ok: true } as Response); + }, 1000); + +/** + * Generates a unique event id for the server event. This matches the id + * used by the bridge-api + * + * @param index - the index of the event + * @returns a unique event id + */ +const getEventId = (index: number) => { + return `${Date.now().toString()}-${index}`; +}; + +/** + * This simulates responses from the fetchEventSource function for unit tests + * onopen, onmessage and onerror callbacks are executed based on this sequence: https://github.com/Azure/fetch-event-source/blob/main/src/fetch.ts#L102-L127 + * + * @param mockQuotes1 - a list of quotes to stream + * @param mockQuotes2 - a list of quotes to stream + * + * @returns a mock of the fetchEventSource function + */ +export const mockSseEventSource = ( + mockQuotes1: QuoteResponse[], + mockQuotes2: QuoteResponse[], +) => + jest + .spyOn(eventSource, 'fetchEventSource') + // Valid quotes + .mockImplementationOnce(async (_, { onopen, onmessage, onclose }) => { + mockOnOpen(onopen); + setTimeout(() => { + mockQuotes1.forEach((quote, id) => { + onmessage?.({ + data: JSON.stringify(quote), + event: 'quote', + id: getEventId(id + 1), + }); + }); + onclose?.(); + }, 4000); + }) + // Valid quotes + .mockImplementationOnce(async (_, { onopen, onmessage, onclose }) => { + mockOnOpen(onopen); + setTimeout(() => { + onmessage?.({ + data: JSON.stringify(mockQuotes2[0]), + event: 'quote', + id: getEventId(1), + }); + }, 9000); + await Promise.resolve(); + setTimeout(() => { + onmessage?.({ + data: JSON.stringify(mockQuotes2[1]), + event: 'quote', + id: getEventId(2), + }); + onclose?.(); + }, 9000); + }) + // Catches a network error + .mockImplementationOnce(async (_, { onopen, onerror, onclose }) => { + mockOnOpen(onopen); + onerror?.('Network error'); + onclose?.(); + }) + .mockImplementationOnce(async (_, { onopen, onmessage, onclose }) => { + mockOnOpen(onopen); + [...mockQuotes1, ...mockQuotes1].forEach((quote, id) => { + setTimeout(() => { + onmessage?.({ + data: JSON.stringify(quote), + event: 'quote', + id: getEventId(id + 1), + }); + // eslint-disable-next-line no-empty-function + Promise.resolve().catch(() => {}); + if (id === mockQuotes1.length - 1) { + onclose?.(); + } + }, 2000 + id); + }); + }) + // Returns valid and invalid quotes + .mockImplementationOnce(async (_, { onopen, onmessage, onclose }) => { + mockOnOpen(onopen); + setTimeout(() => { + onmessage?.({ + data: JSON.stringify({ + ...mockQuotes2[1], + trade: { abc: '123' }, + }), + event: 'quote', + id: 'invalidId', + }); + }, 2000); + setTimeout(() => { + onmessage?.({ + data: '', + event: 'quote', + id: getEventId(2), + }); + }, 3000); + setTimeout(() => { + onmessage?.({ + data: JSON.stringify(mockQuotes2[0]), + event: 'quote', + id: getEventId(3), + }); + }, 4000); + setTimeout(() => { + const { quote, ...rest } = mockQuotes1[0]; + onmessage?.({ + data: JSON.stringify(rest), + event: 'quote', + id: getEventId(4), + }); + onclose?.(); + }, 6000); + }); diff --git a/packages/bridge-controller/tsconfig.json b/packages/bridge-controller/tsconfig.json index 861f0ab721c..5a49183c433 100644 --- a/packages/bridge-controller/tsconfig.json +++ b/packages/bridge-controller/tsconfig.json @@ -16,5 +16,5 @@ { "path": "../multichain-network-controller" }, { "path": "../remote-feature-flag-controller" } ], - "include": ["../../types", "./src"] + "include": ["../../types", "./src", "./tests"] } diff --git a/yarn.lock b/yarn.lock index cbe0fb8b6e1..27cd135efd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2751,6 +2751,7 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/transaction-controller": "npm:^60.6.1" "@metamask/utils": "npm:^11.8.1" + "@microsoft/fetch-event-source": "npm:^2.0.1" "@types/jest": "npm:^27.4.1" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" @@ -4942,6 +4943,13 @@ __metadata: languageName: node linkType: hard +"@microsoft/fetch-event-source@npm:^2.0.1": + version: 2.0.1 + resolution: "@microsoft/fetch-event-source@npm:2.0.1" + checksum: 10/c147055fafe83801efb9834136ba4b7944406a0bba3517afcea6ceeb56714b5ef78c2e89544bd9c1426ad1d2f964a087e3a719169ae04855836a23fb53269f8d + languageName: node + linkType: hard + "@noble/ciphers@npm:^1.2.1, @noble/ciphers@npm:^1.3.0": version: 1.3.0 resolution: "@noble/ciphers@npm:1.3.0"