diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 8a01e9a0f9f9..52fae4d7909a 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -116,8 +116,10 @@ export const SENTRY_BACKGROUND_STATE = { srcTokenAmount: true, }, quotes: [], + quotesInitialLoadTime: true, quotesLastFetched: true, quotesLoadingStatus: true, + quoteFetchError: true, quotesRefreshCount: true, }, }, diff --git a/app/scripts/controllers/bridge/bridge-controller.test.ts b/app/scripts/controllers/bridge/bridge-controller.test.ts index d83844e62cb0..9ffb95832350 100644 --- a/app/scripts/controllers/bridge/bridge-controller.test.ts +++ b/app/scripts/controllers/bridge/bridge-controller.test.ts @@ -413,6 +413,7 @@ describe('BridgeController', function () { ...mockBridgeQuotesNativeErc20Eth, ], quotesLoadingStatus: 1, + quoteFetchError: undefined, quotesRefreshCount: 2, }), ); @@ -433,12 +434,14 @@ describe('BridgeController', function () { ...mockBridgeQuotesNativeErc20Eth, ], quotesLoadingStatus: 2, + quoteFetchError: 'Network error', quotesRefreshCount: 3, }), ); - expect(bridgeController.state.bridgeState.quotesLastFetched).toStrictEqual( - secondFetchTime, - ); + secondFetchTime && + expect( + bridgeController.state.bridgeState.quotesLastFetched, + ).toBeGreaterThan(secondFetchTime); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); @@ -507,6 +510,7 @@ describe('BridgeController', function () { quoteRequest: { ...quoteRequest, walletAddress: undefined }, quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesInitialLoadTime: undefined, quotesLoadingStatus: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, }), @@ -544,6 +548,7 @@ describe('BridgeController', function () { quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, + quotesInitialLoadTime: 11000, }), ); const firstFetchTime = @@ -560,6 +565,7 @@ describe('BridgeController', function () { quotes: mockBridgeQuotesNativeErc20Eth, quotesLoadingStatus: 1, quotesRefreshCount: 1, + quotesInitialLoadTime: 11000, }), ); const secondFetchTime = diff --git a/app/scripts/controllers/bridge/bridge-controller.ts b/app/scripts/controllers/bridge/bridge-controller.ts index 319c5406c259..031725530f52 100644 --- a/app/scripts/controllers/bridge/bridge-controller.ts +++ b/app/scripts/controllers/bridge/bridge-controller.ts @@ -70,6 +70,8 @@ export default class BridgeController extends StaticIntervalPollingController
{ #abortController: AbortController | undefined; + #quotesFirstFetched: number | undefined; + #getLayer1GasFee: (params: { transactionParams: TransactionParams; chainId: ChainId; @@ -150,11 +152,15 @@ export default class BridgeController extends StaticIntervalPollingController
= 1) || - (!updatedQuoteRequest.insufficientBal && - newQuotesRefreshCount >= maxRefreshCount) - ) { - this.stopAllPolling(); - } - const quotesWithL1GasFees = await this.#appendL1GasFees(quotes); this.update((_state) => { _state.bridgeState = { ..._state.bridgeState, quotes: quotesWithL1GasFees, - quotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, - quotesRefreshCount: newQuotesRefreshCount, }; }); } catch (error) { @@ -284,11 +277,39 @@ export default class BridgeController extends StaticIntervalPollingController
{ _state.bridgeState = { ...bridgeState, + quoteFetchError: + error instanceof Error ? error.message : 'Unknown error', quotesLoadingStatus: RequestStatus.ERROR, - quotesRefreshCount: newQuotesRefreshCount, }; }); console.log('Failed to fetch bridge quotes', error); + } finally { + const { maxRefreshCount } = + bridgeState.bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG]; + + const updatedQuotesRefreshCount = bridgeState.quotesRefreshCount + 1; + // Stop polling if the maximum number of refreshes has been reached + if ( + updatedQuoteRequest.insufficientBal || + (!updatedQuoteRequest.insufficientBal && + updatedQuotesRefreshCount >= maxRefreshCount) + ) { + this.stopAllPolling(); + } + + // Update quote fetching stats + const quotesLastFetched = Date.now(); + this.update((_state) => { + _state.bridgeState = { + ..._state.bridgeState, + quotesInitialLoadTime: + updatedQuotesRefreshCount === 1 && this.#quotesFirstFetched + ? quotesLastFetched - this.#quotesFirstFetched + : bridgeState.quotesInitialLoadTime, + quotesLastFetched, + quotesRefreshCount: updatedQuotesRefreshCount, + }; + }); } }; diff --git a/app/scripts/controllers/bridge/constants.ts b/app/scripts/controllers/bridge/constants.ts index 3ad807436062..2d507418b5d9 100644 --- a/app/scripts/controllers/bridge/constants.ts +++ b/app/scripts/controllers/bridge/constants.ts @@ -1,13 +1,15 @@ import { zeroAddress } from 'ethereumjs-util'; import { Hex } from '@metamask/utils'; -import { METABRIDGE_ETHEREUM_ADDRESS } from '../../../../shared/constants/bridge'; +import { + BRIDGE_DEFAULT_SLIPPAGE, + METABRIDGE_ETHEREUM_ADDRESS, +} from '../../../../shared/constants/bridge'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { BridgeControllerState, BridgeFeatureFlagsKey } from './types'; export const BRIDGE_CONTROLLER_NAME = 'BridgeController'; export const REFRESH_INTERVAL_MS = 30 * 1000; const DEFAULT_MAX_REFRESH_COUNT = 5; -const DEFAULT_SLIPPAGE = 0.5; export enum RequestStatus { LOADING, @@ -31,11 +33,13 @@ export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { quoteRequest: { walletAddress: undefined, srcTokenAddress: zeroAddress(), - slippage: DEFAULT_SLIPPAGE, + slippage: BRIDGE_DEFAULT_SLIPPAGE, }, + quotesInitialLoadTime: undefined, quotes: [], quotesLastFetched: undefined, quotesLoadingStatus: undefined, + quoteFetchError: undefined, quotesRefreshCount: 0, }; diff --git a/app/scripts/controllers/bridge/types.ts b/app/scripts/controllers/bridge/types.ts index c9d59a8470f4..6a28eb9d6ffd 100644 --- a/app/scripts/controllers/bridge/types.ts +++ b/app/scripts/controllers/bridge/types.ts @@ -41,8 +41,10 @@ export type BridgeControllerState = { destTopAssets: { address: string }[]; quoteRequest: Partial; quotes: (QuoteResponse & L1GasFees)[]; + quotesInitialLoadTime?: number; quotesLastFetched?: number; quotesLoadingStatus?: RequestStatus; + quoteFetchError?: string; quotesRefreshCount: number; }; diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index 8ad27dce4944..eb2ceb065590 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -30,3 +30,4 @@ export const BRIDGE_QUOTE_MAX_ETA_SECONDS = 60 * 60; // 1 hour export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.8; // if a quote returns in x times less return than the best quote, ignore it export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'medium'; +export const BRIDGE_DEFAULT_SLIPPAGE = 0.5; diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index c87af10544c3..d46bad603a83 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -861,7 +861,6 @@ export enum MetaMetricsEventName { NotificationsActivated = 'Notifications Activated', PushNotificationReceived = 'Push Notification Received', PushNotificationClicked = 'Push Notification Clicked', - // Send sendAssetSelected = 'Send Asset Selected', sendFlowExited = 'Send Flow Exited', @@ -870,6 +869,19 @@ export enum MetaMetricsEventName { sendSwapQuoteRequested = 'Send Swap Quote Requested', sendSwapQuoteReceived = 'Send Swap Quote Received', sendTokenModalOpened = 'Send Token Modal Opened', + // Cross Chain Swaps + ActionCompleted = 'Action Completed', + ActionFailed = 'Action Failed', + ActionOpened = 'Action Opened', + ActionSubmitted = 'Action Submitted', + AllQuotesOpened = 'All Quotes Opened', + AllQuotesSorted = 'All Quotes Sorted', + InputChanged = 'Input Changed', + InputSourceDestinationFlipped = 'Source and Destination Flipped', + CrossChainSwapsQuoteError = 'Cross-chain Quote Error', + QuoteSelected = 'Quote Selected', + CrossChainSwapsQuotesReceived = 'Cross-chain Quotes Received', + CrossChainSwapsQuotesRequested = 'Cross-chain Quotes Requested', } export enum MetaMetricsEventAccountType { @@ -931,6 +943,7 @@ export enum MetaMetricsEventCategory { ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) MMI = 'Institutional', ///: END:ONLY_INCLUDE_IF + CrossChainSwaps = 'Cross Chain Swaps', } export enum MetaMetricsEventLinkType { diff --git a/test/e2e/tests/metrics/errors.spec.js b/test/e2e/tests/metrics/errors.spec.js index b35a38d83206..7ddbe6c1117a 100644 --- a/test/e2e/tests/metrics/errors.spec.js +++ b/test/e2e/tests/metrics/errors.spec.js @@ -877,6 +877,8 @@ describe('Sentry errors', function () { quotesLastFetched: true, quotesLoadingStatus: true, quotesRefreshCount: true, + quoteFetchError: true, + quotesInitialLoadTime: true, }, currentPopupId: false, // Initialized as undefined // Part of transaction controller store, but missing from the initial diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index e315fe10c9a5..f937dcac0cf5 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -722,7 +722,7 @@ export const createBridgeMockStore = ( ...swapsStore, bridge: { toChainId: null, - sortOrder: 0, + sortOrder: 'cost_ascending', ...bridgeSliceOverrides, }, metamask: { diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts index f4638c992906..7b00c95d09a4 100644 --- a/ui/ducks/bridge/bridge.test.ts +++ b/ui/ducks/bridge/bridge.test.ts @@ -150,7 +150,7 @@ describe('Ducks - Bridge', () => { fromToken: null, toToken: null, fromTokenInputValue: null, - sortOrder: 0, + sortOrder: 'cost_ascending', toTokenExchangeRate: null, fromTokenExchangeRate: null, }); @@ -213,7 +213,7 @@ describe('Ducks - Bridge', () => { fromTokenExchangeRate: null, fromTokenInputValue: null, selectedQuote: null, - sortOrder: 0, + sortOrder: 'cost_ascending', toChainId: null, toToken: null, toTokenExchangeRate: null, @@ -260,7 +260,7 @@ describe('Ducks - Bridge', () => { expect(newState).toStrictEqual({ toChainId: null, toTokenExchangeRate: 0.356628, - sortOrder: 0, + sortOrder: 'cost_ascending', }); }); @@ -305,7 +305,7 @@ describe('Ducks - Bridge', () => { expect(newState).toStrictEqual({ toChainId: null, toTokenExchangeRate: 0.999881, - sortOrder: 0, + sortOrder: 'cost_ascending', }); }); }); diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts index edb0c9ca0d13..7abdb8c751e8 100644 --- a/ui/ducks/bridge/bridge.ts +++ b/ui/ducks/bridge/bridge.ts @@ -15,8 +15,8 @@ export type BridgeState = { fromToken: SwapsTokenObject | SwapsEthToken | null; toToken: SwapsTokenObject | SwapsEthToken | null; fromTokenInputValue: string | null; - fromTokenExchangeRate: number | null; - toTokenExchangeRate: number | null; + fromTokenExchangeRate: number | null; // Exchange rate from selected token to the default currency (can be fiat or crypto) + toTokenExchangeRate: number | null; // Exchange rate from the selected token to the default currency (can be fiat or crypto) sortOrder: SortOrder; selectedQuote: (QuoteResponse & QuoteMetadata) | null; // Alternate quote selected by user. When quotes refresh, the best match will be activated. }; @@ -70,6 +70,12 @@ const bridgeSlice = createSlice({ }, }, extraReducers: (builder) => { + builder.addCase(setDestTokenExchangeRates.pending, (state) => { + state.toTokenExchangeRate = null; + }); + builder.addCase(setSrcTokenExchangeRates.pending, (state) => { + state.fromTokenExchangeRate = null; + }); builder.addCase(setDestTokenExchangeRates.fulfilled, (state, action) => { state.toTokenExchangeRate = action.payload ?? null; }); diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts index 50f81fb1fb94..344fab115311 100644 --- a/ui/ducks/bridge/selectors.test.ts +++ b/ui/ducks/bridge/selectors.test.ts @@ -1,4 +1,5 @@ import { BigNumber } from 'bignumber.js'; +import { zeroAddress } from 'ethereumjs-util'; import { createBridgeMockStore } from '../../../test/jest/mock-store'; import { BUILT_IN_NETWORKS, @@ -28,6 +29,7 @@ import { getToToken, getToTokens, getToTopAssets, + getValidationErrors, } from './selectors'; describe('Bridge selectors', () => { @@ -538,10 +540,20 @@ describe('Bridge selectors', () => { describe('getBridgeQuotes', () => { it('returns quote list and fetch data, insufficientBal=false,quotesRefreshCount=5', () => { const state = createBridgeMockStore({ - featureFlagOverrides: { extensionConfig: { maxRefreshCount: 5 } }, + featureFlagOverrides: { + extensionConfig: { + maxRefreshCount: 5, + chains: { + '0xa': { isActiveSrc: true, isActiveDest: false }, + '0x89': { isActiveSrc: false, isActiveDest: true }, + }, + }, + }, bridgeSliceOverrides: { - toChainId: '0x1', + toChainId: '0x89', fromTokenExchangeRate: 1, + fromToken: { address: zeroAddress(), symbol: 'TEST' }, + toToken: { address: zeroAddress(), symbol: 'TEST' }, toTokenExchangeRate: 0.99, toNativeExchangeRate: 0.354073, }, @@ -551,6 +563,7 @@ describe('Bridge selectors', () => { quotesFetchStatus: 1, quotesRefreshCount: 5, quotesLastFetched: 100, + quotesInitialLoadTime: 11000, srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], }, @@ -558,31 +571,43 @@ describe('Bridge selectors', () => { currencyRates: { ETH: { conversionRate: 1, + usdConversionRate: 1, + }, + POL: { + conversionRate: 1, + usdConversionRate: 1, }, }, + marketData: {}, + ...mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.LINEA_MAINNET }, + { chainId: CHAIN_IDS.POLYGON }, + { chainId: CHAIN_IDS.OPTIMISM }, + ), }, }); const recommendedQuoteMetadata = { adjustedReturn: { - fiat: expect.any(Object), + valueInCurrency: expect.any(Object), }, - cost: { fiat: new BigNumber('0.15656287141025952') }, + cost: { valueInCurrency: new BigNumber('0.15656287141025952') }, sentAmount: { - fiat: new BigNumber('14'), + valueInCurrency: new BigNumber('14'), amount: new BigNumber('14'), }, swapRate: new BigNumber('0.998877142857142857142857142857142857'), toTokenAmount: { - fiat: new BigNumber('13.8444372'), + valueInCurrency: new BigNumber('13.8444372'), amount: new BigNumber('13.98428'), }, gasFee: { amount: new BigNumber('7.141025952e-8'), - fiat: new BigNumber('7.141025952e-8'), + valueInCurrency: new BigNumber('7.141025952e-8'), }, totalNetworkFee: { - fiat: new BigNumber('0.00100007141025952'), + valueInCurrency: new BigNumber('0.00100007141025952'), amount: new BigNumber('0.00100007141025952'), }, }; @@ -602,24 +627,36 @@ describe('Bridge selectors', () => { quotesLastFetchedMs: 100, isLoading: false, quotesRefreshCount: 5, + quotesInitialLoadTimeMs: 11000, isQuoteGoingToRefresh: false, + quoteFetchError: undefined, }); }); it('returns quote list and fetch data, insufficientBal=false,quotesRefreshCount=2', () => { const state = createBridgeMockStore({ - featureFlagOverrides: { extensionConfig: { maxRefreshCount: 5 } }, + featureFlagOverrides: { + extensionConfig: { + maxRefreshCount: 5, + chains: { + '0xa': { isActiveSrc: true, isActiveDest: false }, + '0x89': { isActiveSrc: false, isActiveDest: true }, + }, + }, + }, bridgeSliceOverrides: { - toChainId: '0x1', + toChainId: '0x89', + fromToken: { address: zeroAddress(), symbol: 'ETH' }, + toToken: { address: zeroAddress(), symbol: 'TEST' }, fromTokenExchangeRate: 1, toTokenExchangeRate: 0.99, - toNativeExchangeRate: 0.354073, }, bridgeStateOverrides: { quoteRequest: { insufficientBal: false }, quotes: mockErc20Erc20Quotes, quotesFetchStatus: 1, quotesRefreshCount: 2, + quotesInitialLoadTime: 11000, quotesLastFetched: 100, srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], @@ -629,38 +666,49 @@ describe('Bridge selectors', () => { ETH: { conversionRate: 1, }, + POL: { + conversionRate: 0.354073, + usdConversionRate: 1, + }, }, + marketData: {}, + ...mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.LINEA_MAINNET }, + { chainId: CHAIN_IDS.POLYGON }, + { chainId: CHAIN_IDS.OPTIMISM }, + ), }, }); const result = getBridgeQuotes(state as never); const recommendedQuoteMetadata = { adjustedReturn: { - fiat: new BigNumber('13.84343712858974048'), + valueInCurrency: new BigNumber('13.843437128589739081572'), }, - cost: { fiat: new BigNumber('0.15656287141025952') }, + cost: { valueInCurrency: new BigNumber('0.156562871410260918428') }, sentAmount: { - fiat: new BigNumber('14'), + valueInCurrency: new BigNumber('14'), amount: new BigNumber('14'), }, swapRate: new BigNumber('0.998877142857142857142857142857142857'), toTokenAmount: { - fiat: new BigNumber('13.8444372'), + valueInCurrency: new BigNumber('13.844437199999998601572'), amount: new BigNumber('13.98428'), }, gasFee: { amount: new BigNumber('7.141025952e-8'), - fiat: new BigNumber('7.141025952e-8'), + valueInCurrency: new BigNumber('7.141025952e-8'), }, totalNetworkFee: { - fiat: new BigNumber('0.00100007141025952'), + valueInCurrency: new BigNumber('0.00100007141025952'), amount: new BigNumber('0.00100007141025952'), }, }; expect(result.sortedQuotes).toHaveLength(2); const EXPECTED_SORTED_COSTS = [ - { fiat: new BigNumber('0.15656287141025952') }, - { fiat: new BigNumber('0.33900008283534464') }, + { valueInCurrency: new BigNumber('0.156562871410260918428') }, + { valueInCurrency: new BigNumber('0.33900008283534602') }, ]; result.sortedQuotes.forEach((quote, idx) => { expect(quote.cost).toStrictEqual(EXPECTED_SORTED_COSTS[idx]); @@ -679,17 +727,28 @@ describe('Bridge selectors', () => { isLoading: false, quotesRefreshCount: 2, isQuoteGoingToRefresh: true, + quotesInitialLoadTimeMs: 11000, + quoteFetchError: undefined, }); }); it('returns quote list and fetch data, insufficientBal=true', () => { const state = createBridgeMockStore({ - featureFlagOverrides: { extensionConfig: { maxRefreshCount: 5 } }, + featureFlagOverrides: { + extensionConfig: { + maxRefreshCount: 5, + chains: { + '0xa': { isActiveSrc: true, isActiveDest: false }, + '0x89': { isActiveSrc: false, isActiveDest: true }, + }, + }, + }, bridgeSliceOverrides: { - toChainId: '0x1', + toChainId: '0x89', + fromToken: { address: zeroAddress(), symbol: 'ETH' }, + toToken: { address: zeroAddress(), symbol: 'TEST' }, fromTokenExchangeRate: 1, toTokenExchangeRate: 0.99, - toNativeExchangeRate: 0.354073, }, bridgeStateOverrides: { quoteRequest: { insufficientBal: true }, @@ -697,6 +756,7 @@ describe('Bridge selectors', () => { quotesFetchStatus: 1, quotesRefreshCount: 1, quotesLastFetched: 100, + quotesInitialLoadTime: 11000, srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], }, @@ -705,38 +765,49 @@ describe('Bridge selectors', () => { ETH: { conversionRate: 1, }, + POL: { + conversionRate: 1, + usdConversionRate: 1, + }, }, + marketData: {}, + ...mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.LINEA_MAINNET }, + { chainId: CHAIN_IDS.POLYGON }, + { chainId: CHAIN_IDS.OPTIMISM }, + ), }, }); const result = getBridgeQuotes(state as never); const recommendedQuoteMetadata = { adjustedReturn: { - fiat: new BigNumber('13.84343712858974048'), + valueInCurrency: new BigNumber('13.84343712858974048'), }, - cost: { fiat: new BigNumber('0.15656287141025952') }, + cost: { valueInCurrency: new BigNumber('0.15656287141025952') }, sentAmount: { - fiat: new BigNumber('14'), + valueInCurrency: new BigNumber('14'), amount: new BigNumber('14'), }, swapRate: new BigNumber('0.998877142857142857142857142857142857'), toTokenAmount: { - fiat: new BigNumber('13.8444372'), + valueInCurrency: new BigNumber('13.8444372'), amount: new BigNumber('13.98428'), }, gasFee: { amount: new BigNumber('7.141025952e-8'), - fiat: new BigNumber('7.141025952e-8'), + valueInCurrency: new BigNumber('7.141025952e-8'), }, totalNetworkFee: { - fiat: new BigNumber('0.00100007141025952'), + valueInCurrency: new BigNumber('0.00100007141025952'), amount: new BigNumber('0.00100007141025952'), }, }; expect(result.sortedQuotes).toHaveLength(2); const EXPECTED_SORTED_COSTS = [ - { fiat: new BigNumber('0.15656287141025952') }, - { fiat: new BigNumber('0.33900008283534464') }, + { valueInCurrency: new BigNumber('0.15656287141025952') }, + { valueInCurrency: new BigNumber('0.33900008283534464') }, ]; result.sortedQuotes.forEach((quote, idx) => { expect(quote.cost).toStrictEqual(EXPECTED_SORTED_COSTS[idx]); @@ -753,9 +824,11 @@ describe('Bridge selectors', () => { ...recommendedQuoteMetadata, }, quotesLastFetchedMs: 100, + quotesInitialLoadTimeMs: 11000, isLoading: false, quotesRefreshCount: 1, isQuoteGoingToRefresh: false, + quoteFetchError: undefined, }); }); }); @@ -773,7 +846,9 @@ describe('Bridge selectors', () => { quotesLastFetchedMs: undefined, quotesRefreshCount: undefined, recommendedQuote: undefined, + quotesInitialLoadTimeMs: undefined, sortedQuotes: [], + quoteFetchError: undefined, }); }); @@ -889,10 +964,21 @@ describe('Bridge selectors', () => { it('should recommend 2nd fastest quote if adjustedReturn is less than 80% of cheapest quote', () => { const state = createBridgeMockStore({ + featureFlagOverrides: { + extensionConfig: { + chains: { + '0xa': { isActiveSrc: true, isActiveDest: false }, + '0x89': { isActiveSrc: false, isActiveDest: true }, + }, + }, + }, bridgeSliceOverrides: { + toChainId: '0x89', + fromToken: { address: zeroAddress(), symbol: 'ETH' }, + toToken: { address: zeroAddress(), symbol: 'TEST' }, + fromTokenExchangeRate: 2524.25, sortOrder: SortOrder.ETA_ASC, toTokenExchangeRate: 0.998781, - toNativeExchangeRate: 0.354073, }, bridgeStateOverrides: { quotes: [ @@ -913,7 +999,18 @@ describe('Bridge selectors', () => { ETH: { conversionRate: 2524.25, }, + POL: { + conversionRate: 0.354073, + usdConversionRate: 1, + }, }, + marketData: {}, + ...mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.LINEA_MAINNET }, + { chainId: CHAIN_IDS.POLYGON }, + { chainId: CHAIN_IDS.OPTIMISM }, + ), }, }); @@ -934,15 +1031,19 @@ describe('Bridge selectors', () => { expect(recommendedQuote?.quote.requestId).toStrictEqual( '4277a368-40d7-4e82-aa67-74f29dc5f98a', ); - expect(sentAmount?.fiat?.toString()).toStrictEqual('25.2425'); - expect(totalNetworkFee?.fiat?.toString()).toStrictEqual( + expect(sentAmount?.valueInCurrency?.toString()).toStrictEqual('25.2425'); + expect(totalNetworkFee?.valueInCurrency?.toString()).toStrictEqual( '2.52459306428938562', ); - expect(toTokenAmount?.fiat?.toString()).toStrictEqual('24.226654664163'); - expect(adjustedReturn?.fiat?.toString()).toStrictEqual( + expect(toTokenAmount?.valueInCurrency?.toString()).toStrictEqual( + '24.226654664163', + ); + expect(adjustedReturn?.valueInCurrency?.toString()).toStrictEqual( '21.70206159987361438', ); - expect(cost?.fiat?.toString()).toStrictEqual('3.54043840012638562'); + expect(cost?.valueInCurrency?.toString()).toStrictEqual( + '3.54043840012638562', + ); expect(sortedQuotes).toHaveLength(3); expect(sortedQuotes[0]?.quote.requestId).toStrictEqual('fastestQuote'); expect(sortedQuotes[1]?.quote.requestId).toStrictEqual( @@ -953,4 +1054,397 @@ describe('Bridge selectors', () => { ); }); }); + + describe('getValidationErrors', () => { + it('should return isNoQuotesAvailable=true', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { toChainId: '0x1' }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotes: [], + quotesLastFetched: Date.now(), + }, + }); + const result = getValidationErrors(state as never); + + expect(result.isNoQuotesAvailable).toStrictEqual(true); + }); + + it('should return isNoQuotesAvailable=false on initial load', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { toChainId: '0x1' }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotes: [], + }, + }); + const result = getValidationErrors(state as never); + + expect(result.isNoQuotesAvailable).toStrictEqual(false); + }); + + it('should return isInsufficientBalance=true', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { + toChainId: '0x1', + fromTokenInputValue: '0.001', + }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotesLastFetched: Date.now(), + }, + }); + const result = getValidationErrors(state as never); + + expect( + result.isInsufficientBalance(new BigNumber(0.00099)), + ).toStrictEqual(true); + }); + + it('should return isInsufficientBalance=false when there is no input amount', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { toChainId: '0x1' }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotesLastFetched: Date.now(), + }, + }); + const result = getValidationErrors(state as never); + + expect( + result.isInsufficientBalance(new BigNumber(0.00099)), + ).toStrictEqual(false); + }); + + it('should return isInsufficientBalance=false when there is no balance', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { toChainId: '0x1' }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotesLastFetched: Date.now(), + }, + }); + const result = getValidationErrors(state as never); + + expect(result.isInsufficientBalance()).toStrictEqual(false); + }); + + it('should return isInsufficientBalance=false when balance is 0', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { + toChainId: '0x1', + fromTokenInputValue: '0.001', + }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotesLastFetched: Date.now(), + }, + }); + const result = getValidationErrors(state as never); + + expect(result.isInsufficientBalance(new BigNumber(0))).toStrictEqual( + true, + ); + }); + + it('should return isInsufficientGasBalance=true when balance is equal to srcAmount and fromToken is native', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { + toChainId: '0x1', + fromTokenInputValue: '0.001', + fromToken: { address: zeroAddress(), decimals: 18 }, + }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotesLastFetched: Date.now(), + quoteRequest: { srcTokenAmount: '10000000000000000' }, + }, + }); + const result = getValidationErrors(state as never); + + expect( + result.isInsufficientGasBalance(new BigNumber(0.01)), + ).toStrictEqual(true); + }); + + it('should return isInsufficientGasBalance=true when balance is 0 and fromToken is erc20', () => { + const state = createBridgeMockStore({ + featureFlagOverrides: { destNetworkAllowlist: ['0x89'] }, + bridgeSliceOverrides: { + toChainId: '0x89', + toToken: { + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'TEST', + }, + fromTokenInputValue: '0.001', + fromToken: { + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + decimals: 6, + }, + toTokenExchangeRate: 0.798781, + }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotesLastFetched: Date.now(), + quoteRequest: { srcTokenAmount: '100000000' }, + }, + metamaskStateOverrides: { + currencyRates: { + POL: { + conversionRate: 0.354073, + usdConversionRate: 1, + }, + }, + ...mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.LINEA_MAINNET }, + { chainId: CHAIN_IDS.POLYGON }, + ), + }, + }); + const result = getValidationErrors(state as never); + + expect(result.isInsufficientGasBalance(new BigNumber(0))).toStrictEqual( + true, + ); + }); + + it('should return isInsufficientGasBalance=false if there is no fromAmount', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { + toChainId: '0x1', + fromTokenInputValue: '0.001', + }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotesLastFetched: Date.now(), + quoteRequest: {}, + }, + }); + const result = getValidationErrors(state as never); + + expect(result.isInsufficientGasBalance(new BigNumber(0))).toStrictEqual( + false, + ); + }); + + it('should return isInsufficientGasBalance=false when quotes have been loaded', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { + toChainId: '0x1', + fromTokenInputValue: '0.001', + }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotesLastFetched: Date.now(), + quotes: mockErc20Erc20Quotes, + }, + }); + const result = getValidationErrors(state as never); + + expect(result.isInsufficientGasBalance(new BigNumber(0))).toStrictEqual( + false, + ); + }); + + it('should return isInsufficientGasForQuote=true when balance is less than required network fees in quote', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { + toChainId: '0x1', + fromTokenInputValue: '0.001', + fromToken: { address: zeroAddress(), decimals: 18 }, + }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotesLastFetched: Date.now(), + quotes: mockBridgeQuotesNativeErc20, + }, + }); + const result = getValidationErrors(state as never); + + expect( + getBridgeQuotes(state as never).activeQuote?.totalNetworkFee.amount, + ).toStrictEqual(new BigNumber('0.00100012486628784')); + expect( + getBridgeQuotes(state as never).activeQuote?.sentAmount.amount, + ).toStrictEqual(new BigNumber('0.01')); + expect( + result.isInsufficientGasForQuote(new BigNumber(0.001)), + ).toStrictEqual(true); + }); + + it('should return isInsufficientGasForQuote=false when balance is greater than required network fees in quote', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { + toChainId: '0x1', + fromTokenInputValue: '0.001', + fromToken: { address: zeroAddress(), decimals: 18 }, + }, + bridgeStateOverrides: { + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + quotesLastFetched: Date.now(), + quotes: mockBridgeQuotesNativeErc20, + }, + }); + const result = getValidationErrors(state as never); + + expect( + getBridgeQuotes(state as never).activeQuote?.totalNetworkFee.amount, + ).toStrictEqual(new BigNumber('0.00100012486628784')); + expect( + getBridgeQuotes(state as never).activeQuote?.sentAmount.amount, + ).toStrictEqual(new BigNumber('0.01')); + expect( + result.isInsufficientGasForQuote(new BigNumber('0.01100012486628785')), + ).toStrictEqual(false); + }); + + it('should return isEstimatedReturnLow=true return value is 20% less than sent funds', () => { + const state = createBridgeMockStore({ + featureFlagOverrides: { + extensionConfig: { + chains: { + '0xa': { isActiveSrc: true, isActiveDest: false }, + '0x89': { isActiveSrc: false, isActiveDest: true }, + }, + }, + }, + bridgeSliceOverrides: { + toChainId: '0x89', + fromToken: { address: zeroAddress(), symbol: 'ETH' }, + toToken: { address: zeroAddress(), symbol: 'TEST' }, + fromTokenExchangeRate: 2524.25, + toTokenExchangeRate: 0.798781, + }, + bridgeStateOverrides: { + quotes: mockBridgeQuotesNativeErc20, + }, + metamaskStateOverrides: { + currencyRates: { + ETH: { + conversionRate: 2524.25, + }, + POL: { + conversionRate: 0.354073, + usdConversionRate: 1, + }, + }, + marketData: {}, + ...mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.LINEA_MAINNET }, + { chainId: CHAIN_IDS.POLYGON }, + { chainId: CHAIN_IDS.OPTIMISM }, + ), + }, + }); + const result = getValidationErrors(state as never); + + expect( + getBridgeQuotes(state as never).activeQuote?.sentAmount.valueInCurrency, + ).toStrictEqual(new BigNumber('25.2425')); + expect( + getBridgeQuotes(state as never).activeQuote?.totalNetworkFee + .valueInCurrency, + ).toStrictEqual(new BigNumber('2.52456519372708012')); + expect( + getBridgeQuotes(state as never).activeQuote?.adjustedReturn + .valueInCurrency, + ).toStrictEqual(new BigNumber('16.99676538473491988')); + expect(result.isEstimatedReturnLow).toStrictEqual(true); + }); + + it('should return isEstimatedReturnLow=false when return value is more than 80% of sent funds', () => { + const state = createBridgeMockStore({ + featureFlagOverrides: { + extensionConfig: { + chains: { + '0xa': { isActiveSrc: true, isActiveDest: false }, + '0x89': { isActiveSrc: false, isActiveDest: true }, + }, + }, + }, + bridgeSliceOverrides: { + toChainId: '0x89', + fromToken: { address: zeroAddress(), symbol: 'ETH' }, + toToken: { address: zeroAddress(), symbol: 'TEST' }, + fromTokenExchangeRate: 2524.25, + toTokenExchangeRate: 0.998781, + }, + bridgeStateOverrides: { + quotes: mockBridgeQuotesNativeErc20, + }, + metamaskStateOverrides: { + currencyRates: { + ETH: { + conversionRate: 2524.25, + usdConversionRate: 1, + }, + POL: { + conversionRate: 1, + usdConversionRate: 1, + }, + }, + marketData: {}, + ...mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.LINEA_MAINNET }, + { chainId: CHAIN_IDS.POLYGON }, + { chainId: CHAIN_IDS.OPTIMISM }, + ), + }, + }); + const result = getValidationErrors(state as never); + + expect( + getBridgeQuotes(state as never).activeQuote?.sentAmount.valueInCurrency, + ).toStrictEqual(new BigNumber('25.2425')); + expect( + getBridgeQuotes(state as never).activeQuote?.totalNetworkFee + .valueInCurrency, + ).toStrictEqual(new BigNumber('2.52456519372708012')); + expect( + getBridgeQuotes(state as never).activeQuote?.adjustedReturn + .valueInCurrency, + ).toStrictEqual(new BigNumber('21.88454578473491988')); + expect(result.isEstimatedReturnLow).toStrictEqual(false); + }); + + it('should return isEstimatedReturnLow=false if there are no quotes', () => { + const state = createBridgeMockStore({ + bridgeSliceOverrides: { + toTokenExchangeRate: 0.998781, + toNativeExchangeRate: 0.354073, + }, + bridgeStateOverrides: { + quotes: [], + }, + metamaskStateOverrides: { + currencyRates: { + ETH: { + conversionRate: 2524.25, + }, + }, + }, + }); + const result = getValidationErrors(state as never); + + expect(getBridgeQuotes(state as never).activeQuote).toStrictEqual( + undefined, + ); + expect(result.isEstimatedReturnLow).toStrictEqual(false); + }); + }); }); diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 4ad4308535a0..2fd3d9586deb 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -8,7 +8,11 @@ import { GasFeeEstimates } from '@metamask/gas-fee-controller'; import { BigNumber } from 'bignumber.js'; import { getIsBridgeEnabled, + getMarketData, getSwapsDefaultToken, + getUSDConversionRate, + getUSDConversionRateByChainId, + selectConversionRateByChainId, SwapsEthToken, } from '../../selectors/selectors'; import { @@ -50,12 +54,23 @@ import { isNativeAddress, } from '../../pages/bridge/utils/quote'; import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; +import { calcTokenAmount } from '../../../shared/lib/transactions-controller-utils'; +import { + exchangeRatesFromNativeAndCurrencyRates, + exchangeRateFromMarketData, + tokenPriceInNativeAsset, +} from './utils'; import { BridgeState } from './bridge'; type BridgeAppState = { metamask: { bridgeState: BridgeControllerState } & NetworkState & { useExternalServices: boolean; - currencyRates: { [currency: string]: { conversionRate: number } }; + currencyRates: { + [currency: string]: { + conversionRate: number; + usdConversionRate?: number; + }; + }; }; bridge: BridgeState; }; @@ -188,27 +203,76 @@ const _getBridgeFeesPerGas = createSelector( export const getBridgeSortOrder = (state: BridgeAppState) => state.bridge.sortOrder; +export const getFromTokenConversionRate = createSelector( + getFromChain, + getMarketData, + getFromToken, + getUSDConversionRate, + getConversionRate, + (state) => state.bridge.fromTokenExchangeRate, + ( + fromChain, + marketData, + fromToken, + nativeToUsdRate, + nativeToCurrencyRate, + fromTokenExchangeRate, + ) => { + if (fromChain?.chainId && fromToken && marketData) { + const tokenToNativeAssetRate = + exchangeRateFromMarketData( + fromChain.chainId, + fromToken.address, + marketData, + ) ?? + tokenPriceInNativeAsset(fromTokenExchangeRate, nativeToCurrencyRate); + + return exchangeRatesFromNativeAndCurrencyRates( + tokenToNativeAssetRate, + nativeToCurrencyRate, + nativeToUsdRate, + ); + } + return exchangeRatesFromNativeAndCurrencyRates(); + }, +); + // A dest network can be selected before it's imported // The cached exchange rate won't be available so the rate from the bridge state is used -const _getToTokenExchangeRate = createSelector( - (state) => state.metamask.currencyRates, - (state: BridgeAppState) => state.bridge.toTokenExchangeRate, +export const getToTokenConversionRate = createDeepEqualSelector( getToChain, + getMarketData, getToToken, - (cachedCurrencyRates, toTokenExchangeRate, toChain, toToken) => { - return ( - toTokenExchangeRate ?? - (isNativeAddress(toToken?.address) && toChain?.nativeCurrency - ? cachedCurrencyRates[toChain.nativeCurrency]?.conversionRate - : null) - ); + (state) => ({ + state, + toTokenExchangeRate: state.bridge.toTokenExchangeRate, + }), + (toChain, marketData, toToken, { state, toTokenExchangeRate }) => { + if (toChain?.chainId && toToken && marketData) { + const { chainId } = toChain; + + const nativeToCurrencyRate = selectConversionRateByChainId( + state, + chainId, + ); + const nativeToUsdRate = getUSDConversionRateByChainId(chainId)(state); + const tokenToNativeAssetRate = + exchangeRateFromMarketData(chainId, toToken.address, marketData) ?? + tokenPriceInNativeAsset(toTokenExchangeRate, nativeToCurrencyRate); + return exchangeRatesFromNativeAndCurrencyRates( + tokenToNativeAssetRate, + nativeToCurrencyRate, + nativeToUsdRate, + ); + } + return exchangeRatesFromNativeAndCurrencyRates(); }, ); const _getQuotesWithMetadata = createDeepEqualSelector( (state) => state.metamask.bridgeState.quotes, - _getToTokenExchangeRate, - (state: BridgeAppState) => state.bridge.fromTokenExchangeRate, + getToTokenConversionRate, + getFromTokenConversionRate, getConversionRate, _getBridgeFeesPerGas, ( @@ -219,7 +283,10 @@ const _getQuotesWithMetadata = createDeepEqualSelector( { estimatedBaseFeeInDecGwei, maxPriorityFeePerGasInDecGwei }, ): (QuoteResponse & QuoteMetadata)[] => { const newQuotes = quotes.map((quote: QuoteResponse) => { - const toTokenAmount = calcToAmount(quote.quote, toTokenExchangeRate); + const toTokenAmount = calcToAmount( + quote.quote, + toTokenExchangeRate.valueInCurrency, + ); const gasFee = calcTotalGasFee( quote, estimatedBaseFeeInDecGwei, @@ -229,17 +296,17 @@ const _getQuotesWithMetadata = createDeepEqualSelector( const relayerFee = calcRelayerFee(quote, nativeExchangeRate); const totalNetworkFee = { amount: gasFee.amount.plus(relayerFee.amount), - fiat: gasFee.fiat?.plus(relayerFee.fiat || '0') ?? null, + valueInCurrency: + gasFee.valueInCurrency?.plus(relayerFee.valueInCurrency || '0') ?? + null, }; const sentAmount = calcSentAmount( quote.quote, - isNativeAddress(quote.quote.srcAsset.address) - ? nativeExchangeRate - : fromTokenExchangeRate, + fromTokenExchangeRate.valueInCurrency, ); const adjustedReturn = calcAdjustedReturn( - toTokenAmount.fiat, - totalNetworkFee.fiat, + toTokenAmount.valueInCurrency, + totalNetworkFee.valueInCurrency, ); return { @@ -250,7 +317,10 @@ const _getQuotesWithMetadata = createDeepEqualSelector( adjustedReturn, gasFee, swapRate: calcSwapRate(sentAmount.amount, toTokenAmount.amount), - cost: calcCost(adjustedReturn.fiat, sentAmount.fiat), + cost: calcCost( + adjustedReturn.valueInCurrency, + sentAmount.valueInCurrency, + ), }; }); @@ -271,7 +341,11 @@ const _getSortedQuotesWithMetadata = createDeepEqualSelector( ); case SortOrder.COST_ASC: default: - return orderBy(quotesWithMetadata, ({ cost }) => cost.fiat, 'asc'); + return orderBy( + quotesWithMetadata, + ({ cost }) => cost.valueInCurrency, + 'asc', + ); } }, ); @@ -286,15 +360,15 @@ const _getRecommendedQuote = createDeepEqualSelector( const bestReturnValue = BigNumber.max( sortedQuotesWithMetadata.map( - ({ adjustedReturn }) => adjustedReturn.fiat ?? 0, + ({ adjustedReturn }) => adjustedReturn.valueInCurrency ?? 0, ), ); const isFastestQuoteValueReasonable = ( - adjustedReturnInFiat: BigNumber | null, + adjustedReturnInCurrency: BigNumber | null, ) => - adjustedReturnInFiat - ? adjustedReturnInFiat + adjustedReturnInCurrency + ? adjustedReturnInCurrency .div(bestReturnValue) .gte(BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE) : true; @@ -306,7 +380,7 @@ const _getRecommendedQuote = createDeepEqualSelector( return ( sortedQuotesWithMetadata.find((quote) => { return sortOrder === SortOrder.ETA_ASC - ? isFastestQuoteValueReasonable(quote.adjustedReturn.fiat) + ? isFastestQuoteValueReasonable(quote.adjustedReturn.valueInCurrency) : isBestPricedQuoteETAReasonable( quote.estimatedProcessingTimeInSeconds, ); @@ -343,6 +417,8 @@ export const getBridgeQuotes = createSelector( (state) => state.metamask.bridgeState.quotesLoadingStatus === RequestStatus.LOADING, (state: BridgeAppState) => state.metamask.bridgeState.quotesRefreshCount, + (state: BridgeAppState) => state.metamask.bridgeState.quotesInitialLoadTime, + (state: BridgeAppState) => state.metamask.bridgeState.quoteFetchError, getBridgeQuotesConfig, getQuoteRequest, ( @@ -352,6 +428,8 @@ export const getBridgeQuotes = createSelector( quotesLastFetchedMs, isLoading, quotesRefreshCount, + quotesInitialLoadTimeMs, + quoteFetchError, { maxRefreshCount }, { insufficientBal }, ) => ({ @@ -360,7 +438,9 @@ export const getBridgeQuotes = createSelector( activeQuote: selectedQuote ?? recommendedQuote, quotesLastFetchedMs, isLoading, + quoteFetchError, quotesRefreshCount, + quotesInitialLoadTimeMs, isQuoteGoingToRefresh: insufficientBal ? false : quotesRefreshCount < maxRefreshCount, @@ -376,3 +456,86 @@ export const getIsBridgeTx = createDeepEqualSelector( ? fromChain.chainId !== toChain.chainId : false, ); + +const _getValidatedSrcAmount = createSelector( + getFromToken, + (state: BridgeAppState) => + state.metamask.bridgeState.quoteRequest.srcTokenAmount, + (fromToken, srcTokenAmount) => + srcTokenAmount && fromToken?.decimals + ? calcTokenAmount(srcTokenAmount, Number(fromToken.decimals)).toString() + : null, +); + +export const getFromAmountInCurrency = createSelector( + getFromToken, + getFromChain, + _getValidatedSrcAmount, + getFromTokenConversionRate, + ( + fromToken, + fromChain, + validatedSrcAmount, + { valueInCurrency: fromTokenToCurrencyExchangeRate }, + ) => { + if (fromToken?.symbol && fromChain?.chainId && validatedSrcAmount) { + if (fromTokenToCurrencyExchangeRate) { + return new BigNumber(validatedSrcAmount).mul( + new BigNumber(fromTokenToCurrencyExchangeRate.toString() ?? 1), + ); + } + } + return new BigNumber(0); + }, +); + +export const getValidationErrors = createDeepEqualSelector( + getBridgeQuotes, + getFromAmount, + _getValidatedSrcAmount, + getFromToken, + ( + { activeQuote, quotesLastFetchedMs, isLoading }, + fromAmount, + validatedSrcAmount, + fromToken, + ) => { + return { + isNoQuotesAvailable: Boolean( + !activeQuote && quotesLastFetchedMs && !isLoading, + ), + // Shown prior to fetching quotes + isInsufficientGasBalance: (balance?: BigNumber) => { + if (balance && !activeQuote && validatedSrcAmount && fromToken) { + return isNativeAddress(fromToken.address) + ? balance.eq(validatedSrcAmount) + : balance.lte(0); + } + return false; + }, + // Shown after fetching quotes + isInsufficientGasForQuote: (balance?: BigNumber) => { + if (balance && activeQuote && fromToken) { + return isNativeAddress(fromToken.address) + ? balance + .sub(activeQuote.totalNetworkFee.amount) + .sub(activeQuote.sentAmount.amount) + .lte(0) + : balance.lte(activeQuote.totalNetworkFee.amount); + } + return false; + }, + isInsufficientBalance: (balance?: BigNumber) => + fromAmount && balance !== undefined ? balance.lt(fromAmount) : false, + isEstimatedReturnLow: + activeQuote?.sentAmount?.valueInCurrency && + activeQuote?.adjustedReturn?.valueInCurrency + ? activeQuote.adjustedReturn.valueInCurrency.lt( + new BigNumber( + BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, + ).times(activeQuote.sentAmount.valueInCurrency), + ) + : false, + }; + }, +); diff --git a/ui/ducks/bridge/utils.ts b/ui/ducks/bridge/utils.ts index de45111cc10b..1ddd7871d4df 100644 --- a/ui/ducks/bridge/utils.ts +++ b/ui/ducks/bridge/utils.ts @@ -1,11 +1,12 @@ import { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { getAddress } from 'ethers/lib/utils'; +import { ContractMarketData } from '@metamask/assets-controllers'; import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; import { Numeric } from '../../../shared/modules/Numeric'; import { TxData } from '../../pages/bridge/types'; import { getTransaction1559GasFeeEstimates } from '../../pages/swaps/swaps.util'; -import { fetchTokenExchangeRates } from '../../helpers/utils/util'; +import { fetchTokenExchangeRates as fetchTokenExchangeRatesUtil } from '../../helpers/utils/util'; // We don't need to use gas multipliers here because the gasLimit from Bridge API already included it export const getHexMaxGasLimit = (gasLimit: number) => { @@ -48,6 +49,28 @@ export const getTxGasEstimates = async ({ }; }; +const fetchTokenExchangeRates = async ( + chainId: string, + currency: string, + ...tokenAddresses: string[] +) => { + const exchangeRates = await fetchTokenExchangeRatesUtil( + currency, + tokenAddresses, + chainId, + ); + return Object.keys(exchangeRates).reduce( + (acc: Record, address) => { + acc[address.toLowerCase()] = exchangeRates[address]; + return acc; + }, + {}, + ); +}; + +// This fetches the exchange rate for a token in a given currency. This is only called when the exchange +// rate is not available in the TokenRatesController, which happens when the selected token has not been +// imported into the wallet export const getTokenExchangeRate = async (request: { chainId: Hex; tokenAddress: string; @@ -55,12 +78,58 @@ export const getTokenExchangeRate = async (request: { }) => { const { chainId, tokenAddress, currency } = request; const exchangeRates = await fetchTokenExchangeRates( - currency, - [tokenAddress], chainId, + currency, + tokenAddress, ); - return ( + const exchangeRate = exchangeRates?.[tokenAddress.toLowerCase()] ?? - exchangeRates?.[getAddress(tokenAddress)] - ); + exchangeRates?.[getAddress(tokenAddress)]; + return exchangeRate; +}; + +// This extracts a token's exchange rate from the marketData state object +export const exchangeRateFromMarketData = ( + chainId: string, + tokenAddress: string, + marketData?: Record, +) => + ( + marketData?.[chainId]?.[tokenAddress.toLowerCase() as Hex] ?? + marketData?.[chainId]?.[getAddress(tokenAddress) as Hex] + )?.price; + +export const tokenAmountToCurrency = ( + amount: string | BigNumber, + exchangeRate: number, +) => + new Numeric(amount, 10) + // Stringify exchangeRate before applying conversion to avoid floating point issues + .applyConversionRate(new BigNumber(exchangeRate.toString(), 10)) + .toNumber(); + +export const tokenPriceInNativeAsset = ( + tokenExchangeRate?: number | null, + nativeToCurrencyRate?: number | null, +) => { + return tokenExchangeRate && nativeToCurrencyRate + ? tokenExchangeRate / nativeToCurrencyRate + : null; +}; + +export const exchangeRatesFromNativeAndCurrencyRates = ( + tokenToNativeAssetRate?: number | null, + nativeToCurrencyRate?: number | null, + nativeToUsdRate?: number | null, +) => { + return { + valueInCurrency: + tokenToNativeAssetRate && nativeToCurrencyRate + ? tokenToNativeAssetRate * nativeToCurrencyRate + : null, + usd: + tokenToNativeAssetRate && nativeToUsdRate + ? tokenToNativeAssetRate * nativeToUsdRate + : null, + }; }; diff --git a/ui/helpers/constants/routes.ts b/ui/helpers/constants/routes.ts index 330d3ad20815..da12a52be812 100644 --- a/ui/helpers/constants/routes.ts +++ b/ui/helpers/constants/routes.ts @@ -232,6 +232,7 @@ PATH_NAME_MAP[ ] = 'Encryption Public Key Request Page'; export const CROSS_CHAIN_SWAP_ROUTE = '/cross-chain'; +PATH_NAME_MAP[CROSS_CHAIN_SWAP_ROUTE] = 'Prepare Cross Chain Swap Page'; export const CROSS_CHAIN_SWAP_TX_DETAILS_ROUTE = '/cross-chain/tx-details'; export const SWAPS_ROUTE = '/swaps'; diff --git a/ui/hooks/bridge/events/types.ts b/ui/hooks/bridge/events/types.ts new file mode 100644 index 000000000000..53c97b5c245b --- /dev/null +++ b/ui/hooks/bridge/events/types.ts @@ -0,0 +1,47 @@ +import { StatusTypes } from '../../../../shared/types/bridge-status'; + +export enum ActionType { + CROSSCHAIN_V1 = 'crosschain-v1', + SWAPBRIDGE_V1 = 'swapbridge-v1', +} + +export type RequestParams = { + chain_id_source: string; + chain_id_destination?: string; + token_symbol_source: string; + token_symbol_destination?: string; + token_address_source: string; + token_address_destination?: string; +}; + +export type RequestMetadata = { + slippage_limit: number; + custom_slippage: boolean; + usd_amount_source: number; + stx_enabled: boolean; + is_hardware_wallet: boolean; + swap_type: ActionType; +}; + +export type QuoteFetchData = { + can_submit: boolean; + best_quote_provider?: `${string}_${string}`; + quotes_count: number; + quotes_list: `${string}_${string}`[]; + initial_load_time_all_quotes: number; +}; + +export type TradeData = { + usd_quoted_gas: number; + gas_included: boolean; + quoted_time_minutes: number; + usd_quoted_return: number; + provider: `${string}_${string}`; +}; + +export type TxStatusData = { + allowance_reset_transaction?: StatusTypes; + approval_transaction?: StatusTypes; + source_transaction: StatusTypes; + destination_transaction?: StatusTypes; +}; diff --git a/ui/hooks/bridge/events/useConvertedUsdAmounts.ts b/ui/hooks/bridge/events/useConvertedUsdAmounts.ts new file mode 100644 index 000000000000..7ad432579c43 --- /dev/null +++ b/ui/hooks/bridge/events/useConvertedUsdAmounts.ts @@ -0,0 +1,75 @@ +/* eslint-disable camelcase */ +import { useSelector } from 'react-redux'; +import { + getBridgeQuotes, + getFromAmountInCurrency, + getFromTokenConversionRate, + getQuoteRequest, + getToTokenConversionRate, + getFromAmount, +} from '../../../ducks/bridge/selectors'; +import { getCurrentCurrency, getUSDConversionRate } from '../../../selectors'; +import { tokenAmountToCurrency } from '../../../ducks/bridge/utils'; + +const USD_CURRENCY_CODE = 'usd'; + +// This hook is used to get the converted USD amounts for the bridge trade +// It returns the converted token value if the user's selected currency is USD +// Otherwise, it converts the token amounts to USD using the exchange rates +// If the amount's usd value is not available, it defaults to 0 +export const useConvertedUsdAmounts = () => { + const { srcTokenAddress, destTokenAddress } = useSelector(getQuoteRequest); + const { activeQuote } = useSelector(getBridgeQuotes); + const fromAmountInputValueInCurrency = useSelector(getFromAmountInCurrency); + const fromAmountInputValue = useSelector(getFromAmount); + const fromTokenConversionRate = useSelector(getFromTokenConversionRate); + const toTokenConversionRate = useSelector(getToTokenConversionRate); + const currency = useSelector(getCurrentCurrency); + const nativeToUsdRate = useSelector(getUSDConversionRate); + + // Use values from activeQuote if available, otherwise use validated input field values + const fromTokenAddress = ( + activeQuote ? activeQuote.quote.srcAsset.address : srcTokenAddress + )?.toLowerCase(); + const toTokenAddress = ( + activeQuote ? activeQuote.quote.destAsset.address : destTokenAddress + )?.toLowerCase(); + + const fromAmountInCurrency = + activeQuote?.sentAmount?.valueInCurrency ?? fromAmountInputValueInCurrency; + const fromAmount = fromAmountInputValue ?? activeQuote?.sentAmount.amount; + + const isCurrencyUsd = currency.toLowerCase() === USD_CURRENCY_CODE; + + return { + // If a quote is passed in, derive the usd amount source from the quote + // otherwise use input field values + usd_amount_source: isCurrencyUsd + ? fromAmountInCurrency.toNumber() + : (fromTokenConversionRate?.usd && + fromAmount && + fromTokenAddress && + tokenAmountToCurrency(fromAmount, fromTokenConversionRate.usd)) || + 0, + // If user's selected currency is not usd, use usd exchange rates for + // the gas token and convert the quoted gas amount to usd + usd_quoted_gas: + (isCurrencyUsd + ? activeQuote?.gasFee.valueInCurrency?.toNumber() + : activeQuote?.gasFee.amount && + tokenAmountToCurrency(activeQuote.gasFee.amount, nativeToUsdRate)) || + 0, + // If user's selected currency is not usd, use usd exchange rates for + // the dest asset and convert the dest amount to usd + usd_quoted_return: + (isCurrencyUsd + ? activeQuote?.toTokenAmount?.valueInCurrency?.toNumber() + : activeQuote?.toTokenAmount?.amount && + toTokenAddress && + toTokenConversionRate.usd && + tokenAmountToCurrency( + activeQuote.toTokenAmount.amount, + toTokenConversionRate.usd, + )) || 0, + }; +}; diff --git a/ui/hooks/bridge/events/useQuoteProperties.ts b/ui/hooks/bridge/events/useQuoteProperties.ts new file mode 100644 index 000000000000..67c0e9a2b915 --- /dev/null +++ b/ui/hooks/bridge/events/useQuoteProperties.ts @@ -0,0 +1,25 @@ +/* eslint-disable camelcase */ +import { useSelector } from 'react-redux'; +import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; +import { formatProviderLabel } from '../../../pages/bridge/utils/quote'; +import { useIsTxSubmittable } from '../useIsTxSubmittable'; +import { SECOND } from '../../../../shared/constants/time'; + +export const useQuoteProperties = () => { + const { recommendedQuote, sortedQuotes, quotesInitialLoadTimeMs } = + useSelector(getBridgeQuotes); + + const can_submit = useIsTxSubmittable(); + + const initial_load_time_all_quotes = quotesInitialLoadTimeMs + ? quotesInitialLoadTimeMs / SECOND + : 0; + + return { + can_submit, + best_quote_provider: formatProviderLabel(recommendedQuote), + quotes_count: sortedQuotes.length, + quotes_list: sortedQuotes.map(formatProviderLabel), + initial_load_time_all_quotes, + }; +}; diff --git a/ui/hooks/bridge/events/useRequestMetadataProperties.ts b/ui/hooks/bridge/events/useRequestMetadataProperties.ts new file mode 100644 index 000000000000..d0effe026803 --- /dev/null +++ b/ui/hooks/bridge/events/useRequestMetadataProperties.ts @@ -0,0 +1,38 @@ +/* eslint-disable camelcase */ +import { useSelector } from 'react-redux'; +import { + getIsBridgeTx, + getQuoteRequest, +} from '../../../ducks/bridge/selectors'; +import { isHardwareKeyring } from '../../../helpers/utils/hardware'; +import { getCurrentKeyring } from '../../../selectors'; +import { getIsSmartTransaction } from '../../../../shared/modules/selectors'; +import { BRIDGE_DEFAULT_SLIPPAGE } from '../../../../shared/constants/bridge'; +import { ActionType } from './types'; +import { useConvertedUsdAmounts } from './useConvertedUsdAmounts'; + +export const useRequestMetadataProperties = () => { + const { slippage } = useSelector(getQuoteRequest); + const isBridgeTx = useSelector(getIsBridgeTx); + const stx_enabled = useSelector(getIsSmartTransaction); + const { usd_amount_source } = useConvertedUsdAmounts(); + + const keyring = useSelector(getCurrentKeyring); + // @ts-expect-error keyring type is possibly wrong + const is_hardware_wallet = isHardwareKeyring(keyring.type) ?? false; + + const slippage_limit = slippage; + const swap_type = isBridgeTx + ? ActionType.CROSSCHAIN_V1 + : ActionType.SWAPBRIDGE_V1; + const custom_slippage = slippage_limit !== BRIDGE_DEFAULT_SLIPPAGE; + + return { + slippage_limit: slippage ?? BRIDGE_DEFAULT_SLIPPAGE, + custom_slippage, + is_hardware_wallet, + swap_type, + stx_enabled, + usd_amount_source, + }; +}; diff --git a/ui/hooks/bridge/events/useRequestProperties.ts b/ui/hooks/bridge/events/useRequestProperties.ts new file mode 100644 index 000000000000..5477436f46a5 --- /dev/null +++ b/ui/hooks/bridge/events/useRequestProperties.ts @@ -0,0 +1,54 @@ +/* eslint-disable camelcase */ +import { useSelector } from 'react-redux'; +import { + getQuoteRequest, + getFromToken, + getToToken, +} from '../../../ducks/bridge/selectors'; +import { Numeric } from '../../../../shared/modules/Numeric'; + +export const useRequestProperties = () => { + const { srcChainId, destChainId, srcTokenAddress, destTokenAddress } = + useSelector(getQuoteRequest); + const fromToken = useSelector(getFromToken); + const toToken = useSelector(getToToken); + + const chain_id_source = + srcChainId && new Numeric(srcChainId, 10).toPrefixedHexString(); + const chain_id_destination = + destChainId && new Numeric(destChainId, 10).toPrefixedHexString(); + const token_symbol_source = fromToken?.symbol; + const token_symbol_destination = toToken?.symbol; + const token_address_source = srcTokenAddress?.toLowerCase(); + const token_address_destination = destTokenAddress?.toLowerCase(); + + if ( + chain_id_source && + chain_id_destination && + token_address_source && + token_address_destination && + token_symbol_source && + token_symbol_destination + ) { + return { + quoteRequestProperties: { + chain_id_source, + chain_id_destination, + token_symbol_source, + token_symbol_destination, + token_address_source, + token_address_destination, + }, + flippedRequestProperties: { + chain_id_source: chain_id_destination, + chain_id_destination: chain_id_source, + token_symbol_source: token_symbol_destination, + token_symbol_destination: token_symbol_source, + token_address_source: token_address_destination, + token_address_destination: token_address_source, + }, + }; + } + + return {}; +}; diff --git a/ui/hooks/bridge/events/useTradeProperties.ts b/ui/hooks/bridge/events/useTradeProperties.ts new file mode 100644 index 000000000000..361d271ac371 --- /dev/null +++ b/ui/hooks/bridge/events/useTradeProperties.ts @@ -0,0 +1,24 @@ +/* eslint-disable camelcase */ +import { useSelector } from 'react-redux'; +import { getBridgeQuotes } from '../../../ducks/bridge/selectors'; +import { formatProviderLabel } from '../../../pages/bridge/utils/quote'; +import { useConvertedUsdAmounts } from './useConvertedUsdAmounts'; + +export const useTradeProperties = () => { + const { activeQuote } = useSelector(getBridgeQuotes); + const { usd_amount_source, usd_quoted_gas, usd_quoted_return } = + useConvertedUsdAmounts(); + + const quoted_time_minutes = activeQuote?.estimatedProcessingTimeInSeconds + ? activeQuote.estimatedProcessingTimeInSeconds / 60 + : 0; + + return { + gas_included: false, // TODO check if trade has gas included + quoted_time_minutes, + provider: formatProviderLabel(activeQuote), + usd_amount_source, + usd_quoted_gas, + usd_quoted_return, + }; +}; diff --git a/ui/hooks/bridge/useBridgeExchangeRates.ts b/ui/hooks/bridge/useBridgeExchangeRates.ts new file mode 100644 index 000000000000..ef3c6669a2c8 --- /dev/null +++ b/ui/hooks/bridge/useBridgeExchangeRates.ts @@ -0,0 +1,84 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + getBridgeQuotes, + getQuoteRequest, + getToChain, +} from '../../ducks/bridge/selectors'; +import { getCurrentCurrency, getMarketData } from '../../selectors'; +import { decimalToPrefixedHex } from '../../../shared/modules/conversion.utils'; +import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; +import { + setDestTokenExchangeRates, + setSrcTokenExchangeRates, +} from '../../ducks/bridge/bridge'; +import { exchangeRateFromMarketData } from '../../ducks/bridge/utils'; + +export const useBridgeExchangeRates = () => { + const { srcTokenAddress, destTokenAddress } = useSelector(getQuoteRequest); + const { activeQuote } = useSelector(getBridgeQuotes); + const chainId = useSelector(getCurrentChainId); + const toChain = useSelector(getToChain); + + const dispatch = useDispatch(); + + const currency = useSelector(getCurrentCurrency); + + // Use values from activeQuote if available, otherwise use validated input field values + const fromTokenAddress = ( + activeQuote ? activeQuote.quote.srcAsset.address : srcTokenAddress + )?.toLowerCase(); + const toTokenAddress = ( + activeQuote ? activeQuote.quote.destAsset.address : destTokenAddress + )?.toLowerCase(); + const fromChainId = activeQuote + ? decimalToPrefixedHex(activeQuote.quote.srcChainId) + : chainId; + const toChainId = activeQuote + ? decimalToPrefixedHex(activeQuote.quote.destChainId) + : toChain?.chainId; + + const marketData = useSelector(getMarketData); + + // Fetch exchange rates for selected src token if not found in marketData + useEffect(() => { + if (fromChainId && fromTokenAddress) { + const exchangeRate = exchangeRateFromMarketData( + fromChainId, + fromTokenAddress, + marketData, + ); + + if (!exchangeRate) { + dispatch( + setSrcTokenExchangeRates({ + chainId: fromChainId, + tokenAddress: fromTokenAddress, + currency, + }), + ); + } + } + }, [fromChainId, fromTokenAddress]); + + // Fetch exchange rates for selected dest token if not found in marketData + useEffect(() => { + if (toChainId && toTokenAddress) { + const exchangeRate = exchangeRateFromMarketData( + toChainId, + toTokenAddress, + marketData, + ); + + if (!exchangeRate) { + dispatch( + setDestTokenExchangeRates({ + chainId: toChainId, + tokenAddress: toTokenAddress, + currency, + }), + ); + } + } + }, [toChainId, toTokenAddress]); +}; diff --git a/ui/hooks/bridge/useBridging.ts b/ui/hooks/bridge/useBridging.ts index 62945e3e7a92..a8307658e285 100644 --- a/ui/hooks/bridge/useBridging.ts +++ b/ui/hooks/bridge/useBridging.ts @@ -17,6 +17,7 @@ import { MetaMetricsContext } from '../../contexts/metametrics'; import { MetaMetricsEventCategory, MetaMetricsEventName, + MetaMetricsSwapsEventSource, } from '../../../shared/constants/metametrics'; import { @@ -30,12 +31,14 @@ import { isHardwareKeyring } from '../../helpers/utils/hardware'; import { getPortfolioUrl } from '../../helpers/utils/portfolio'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { getProviderConfig } from '../../../shared/modules/selectors/networks'; +import { useCrossChainSwapsEventTracker } from './useCrossChainSwapsEventTracker'; ///: END:ONLY_INCLUDE_IF const useBridging = () => { const dispatch = useDispatch(); const history = useHistory(); const trackEvent = useContext(MetaMetricsContext); + const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker(); const metaMetricsId = useSelector(getMetaMetricsId); const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); @@ -63,6 +66,19 @@ const useBridging = () => { } if (isBridgeSupported) { + trackCrossChainSwapsEvent({ + event: MetaMetricsEventName.ActionOpened, + category: MetaMetricsEventCategory.Navigation, + properties: { + location: + location === 'Home' + ? MetaMetricsSwapsEventSource.MainView + : MetaMetricsSwapsEventSource.TokenView, + chain_id_source: providerConfig.chainId, + token_symbol_source: token.symbol, + token_address_source: token.address, + }, + }); trackEvent({ event: MetaMetricsEventName.BridgeLinkClicked, category: MetaMetricsEventCategory.Navigation, @@ -118,6 +134,7 @@ const useBridging = () => { history, metaMetricsId, trackEvent, + trackCrossChainSwapsEvent, isMetaMetricsEnabled, isMarketingEnabled, providerConfig, diff --git a/ui/hooks/bridge/useCrossChainSwapsEventTracker.ts b/ui/hooks/bridge/useCrossChainSwapsEventTracker.ts new file mode 100644 index 000000000000..f28244cba995 --- /dev/null +++ b/ui/hooks/bridge/useCrossChainSwapsEventTracker.ts @@ -0,0 +1,113 @@ +import { useCallback, useContext } from 'react'; +import { MetaMetricsContext } from '../../contexts/metametrics'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, + MetaMetricsSwapsEventSource, +} from '../../../shared/constants/metametrics'; +import { SortOrder } from '../../pages/bridge/types'; +import { + RequestParams, + RequestMetadata, + TradeData, + QuoteFetchData, + ActionType, + TxStatusData, +} from './events/types'; + +export type CrossChainSwapsEventProperties = { + [MetaMetricsEventName.ActionOpened]: RequestParams & { + location: MetaMetricsSwapsEventSource; + }; + [MetaMetricsEventName.ActionCompleted]: RequestParams & + RequestMetadata & + TradeData & + TxStatusData & { + usd_actual_return: number; + actual_time_minutes: number; + quote_vs_execution_ratio: number; + quoted_vs_used_gas_ratio: number; + }; + [MetaMetricsEventName.ActionSubmitted]: RequestParams & + RequestMetadata & + TradeData; + [MetaMetricsEventName.ActionFailed]: RequestParams & + RequestMetadata & + TradeData & + TxStatusData & { + actual_time_minutes: number; + error_message: string; + }; + [MetaMetricsEventName.CrossChainSwapsQuotesRequested]: RequestParams & + RequestMetadata & { + has_sufficient_funds: boolean; + }; + [MetaMetricsEventName.AllQuotesOpened]: RequestParams & + RequestMetadata & + QuoteFetchData; + [MetaMetricsEventName.AllQuotesSorted]: RequestParams & + RequestMetadata & + QuoteFetchData & { sort_order: SortOrder }; + [MetaMetricsEventName.QuoteSelected]: RequestParams & + RequestMetadata & + QuoteFetchData & + TradeData & { + is_best_quote: boolean; + }; + [MetaMetricsEventName.CrossChainSwapsQuotesReceived]: RequestParams & + RequestMetadata & + QuoteFetchData & + TradeData & { + refresh_count: number; // starts from 0 + warnings: string[]; + }; + [MetaMetricsEventName.InputSourceDestinationFlipped]: RequestParams; + [MetaMetricsEventName.InputChanged]: { + input: + | 'token_source' + | 'token_destination' + | 'chain_source' + | 'chain_destination' + | 'slippage'; + value: string; + }; + [MetaMetricsEventName.CrossChainSwapsQuoteError]: RequestParams & + RequestMetadata & { + error_message: string; + has_sufficient_funds: boolean; + }; +}; + +/** + * Returns trackCrossChainSwapsEvent method, which emits metrics using the provided event name, category, and properties. + * The callback has type-safe parameters to ensure input properties satisfy the required event properties defined in CrossChainSwapsEventProperties. + * + * @returns The trackCrossChainSwapsEvent method which wraps the MetaMetricsContext trackEvent method. + */ +export const useCrossChainSwapsEventTracker = () => { + const trackEvent = useContext(MetaMetricsContext); + + const trackCrossChainSwapsEvent = useCallback( + ({ + event, + category, + properties, + }: { + event: EventName; + category?: MetaMetricsEventCategory; + properties: CrossChainSwapsEventProperties[EventName]; + }) => { + trackEvent({ + category: category ?? MetaMetricsEventCategory.CrossChainSwaps, + event, + properties: { + action_type: ActionType.CROSSCHAIN_V1, + ...properties, + }, + }); + }, + [trackEvent], + ); + + return trackCrossChainSwapsEvent; +}; diff --git a/ui/hooks/bridge/useIsTxSubmittable.ts b/ui/hooks/bridge/useIsTxSubmittable.ts new file mode 100644 index 000000000000..8302001c5bf8 --- /dev/null +++ b/ui/hooks/bridge/useIsTxSubmittable.ts @@ -0,0 +1,49 @@ +import { useSelector } from 'react-redux'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../shared/constants/swaps'; +import { + getBridgeQuotes, + getFromAmount, + getFromChain, + getFromToken, + getToChain, + getValidationErrors, + getToToken, +} from '../../ducks/bridge/selectors'; +import useLatestBalance from './useLatestBalance'; + +export const useIsTxSubmittable = () => { + const fromToken = useSelector(getFromToken); + const toToken = useSelector(getToToken); + const fromChain = useSelector(getFromChain); + const toChain = useSelector(getToChain); + const fromAmount = useSelector(getFromAmount); + const { activeQuote } = useSelector(getBridgeQuotes); + + const { + isInsufficientBalance, + isInsufficientGasBalance, + isInsufficientGasForQuote, + } = useSelector(getValidationErrors); + + const { balanceAmount } = useLatestBalance(fromToken, fromChain?.chainId); + const { balanceAmount: nativeAssetBalance } = useLatestBalance( + fromChain?.chainId + ? SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + fromChain.chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ] + : null, + fromChain?.chainId, + ); + + return Boolean( + fromToken && + toToken && + fromChain && + toChain && + fromAmount && + activeQuote && + !isInsufficientBalance(balanceAmount) && + !isInsufficientGasBalance(nativeAssetBalance) && + !isInsufficientGasForQuote(nativeAssetBalance), + ); +}; diff --git a/ui/hooks/bridge/useLatestBalance.ts b/ui/hooks/bridge/useLatestBalance.ts index b3398dc18333..6aaf7da68c0c 100644 --- a/ui/hooks/bridge/useLatestBalance.ts +++ b/ui/hooks/bridge/useLatestBalance.ts @@ -7,6 +7,7 @@ import { getSelectedInternalAccount, SwapsEthToken } from '../../selectors'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { calcLatestSrcBalance } from '../../../shared/modules/bridge-utils/balance'; import { useAsyncResult } from '../useAsyncResult'; +import { calcTokenAmount } from '../../../shared/lib/transactions-controller-utils'; /** * Custom hook to fetch and format the latest balance of a given token or native asset. @@ -58,6 +59,10 @@ const useLatestBalance = ( .round(DEFAULT_PRECISION) .toString() : undefined, + balanceAmount: + token && latestBalance + ? calcTokenAmount(latestBalance.toString(), tokenDecimals) + : undefined, }; }; diff --git a/ui/hooks/bridge/useQuoteFetchEvents.ts b/ui/hooks/bridge/useQuoteFetchEvents.ts new file mode 100644 index 000000000000..a272430c3f2a --- /dev/null +++ b/ui/hooks/bridge/useQuoteFetchEvents.ts @@ -0,0 +1,151 @@ +/* eslint-disable camelcase */ +import { useEffect, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { MetaMetricsEventName } from '../../../shared/constants/metametrics'; +import { + getBridgeQuotes, + getFromAmount, + getFromChain, + getFromToken, + getQuoteRequest, + getValidationErrors, +} from '../../ducks/bridge/selectors'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../shared/constants/swaps'; +import { useCrossChainSwapsEventTracker } from './useCrossChainSwapsEventTracker'; +import useLatestBalance from './useLatestBalance'; +import { useRequestMetadataProperties } from './events/useRequestMetadataProperties'; +import { useRequestProperties } from './events/useRequestProperties'; +import { useTradeProperties } from './events/useTradeProperties'; +import { useConvertedUsdAmounts } from './events/useConvertedUsdAmounts'; +import { useQuoteProperties } from './events/useQuoteProperties'; + +// This hook is used to track cross chain swaps events related to quote-fetching +export const useQuoteFetchEvents = () => { + const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker(); + const { + isLoading, + quotesRefreshCount, + quoteFetchError, + quotesInitialLoadTimeMs, + } = useSelector(getBridgeQuotes); + const { insufficientBal, srcTokenAddress } = useSelector(getQuoteRequest); + const fromTokenInputValue = useSelector(getFromAmount); + const validationErrors = useSelector(getValidationErrors); + + const { quoteRequestProperties } = useRequestProperties(); + const requestMetadataProperties = useRequestMetadataProperties(); + const { usd_amount_source } = useConvertedUsdAmounts(); + + const has_sufficient_funds = !insufficientBal; + + const quoteListProperties = useQuoteProperties(); + const tradeProperties = useTradeProperties(); + + const fromToken = useSelector(getFromToken); + const fromChain = useSelector(getFromChain); + + const { balanceAmount } = useLatestBalance(fromToken, fromChain?.chainId); + const { balanceAmount: nativeAssetBalance } = useLatestBalance( + fromChain?.chainId + ? SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + fromChain.chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ] + : null, + fromChain?.chainId, + ); + + const warnings = useMemo(() => { + const { + isEstimatedReturnLow, + isNoQuotesAvailable, + isInsufficientGasBalance, + isInsufficientGasForQuote, + isInsufficientBalance, + } = validationErrors; + + const latestWarnings = []; + + isEstimatedReturnLow && latestWarnings.push('low_return'); + isNoQuotesAvailable && latestWarnings.push('no_quotes'); + isInsufficientGasBalance(nativeAssetBalance) && + latestWarnings.push('insufficient_gas_balance'); + isInsufficientGasForQuote(nativeAssetBalance) && + latestWarnings.push('insufficient_gas_for_selected_quote'); + isInsufficientBalance(balanceAmount) && + latestWarnings.push('insufficient_balance'); + + return latestWarnings; + }, [validationErrors]); + + // Emitted when quotes are fetched for the first time for a given request + useEffect(() => { + const isInitialFetch = + isLoading && + quotesRefreshCount === 0 && + quotesInitialLoadTimeMs === undefined; + + if ( + quoteRequestProperties && + fromTokenInputValue && + srcTokenAddress && + isInitialFetch + ) { + trackCrossChainSwapsEvent({ + event: MetaMetricsEventName.CrossChainSwapsQuotesRequested, + properties: { + ...quoteRequestProperties, + ...requestMetadataProperties, + has_sufficient_funds, + usd_amount_source, + }, + }); + } + }, [isLoading, quotesInitialLoadTimeMs]); + + // Emitted when an error is caught during fetch + useEffect(() => { + if ( + quoteRequestProperties && + fromTokenInputValue && + srcTokenAddress && + quoteFetchError + ) { + trackCrossChainSwapsEvent({ + event: MetaMetricsEventName.CrossChainSwapsQuoteError, + properties: { + ...quoteRequestProperties, + ...requestMetadataProperties, + has_sufficient_funds, + usd_amount_source, + error_message: quoteFetchError, + }, + }); + } + }, [quoteFetchError]); + + // Emitted after each time quotes are fetched successfully + useEffect(() => { + if ( + fromTokenInputValue && + srcTokenAddress && + !isLoading && + quotesRefreshCount >= 0 && + quoteListProperties.initial_load_time_all_quotes > 0 && + quoteRequestProperties && + !quoteFetchError && + tradeProperties + ) { + trackCrossChainSwapsEvent({ + event: MetaMetricsEventName.CrossChainSwapsQuotesReceived, + properties: { + ...quoteRequestProperties, + ...requestMetadataProperties, + ...quoteListProperties, + ...tradeProperties, + refresh_count: quotesRefreshCount - 1, + warnings, + }, + }); + } + }, [quotesRefreshCount]); +}; diff --git a/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts index a0fd8bc73508..5750e398b1ff 100644 --- a/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts +++ b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts @@ -20,6 +20,7 @@ export default function useSubmitBridgeTransaction() { const submitBridgeTransaction = async ( quoteResponse: QuoteResponse & QuoteMetadata, ) => { + // TODO catch errors and emit ActionFailed here // Execute transaction(s) let approvalTxMeta: TransactionMeta | undefined; if (quoteResponse?.approval) { diff --git a/ui/pages/bridge/index.tsx b/ui/pages/bridge/index.tsx index 6dd54b424d06..9eddeffee798 100644 --- a/ui/pages/bridge/index.tsx +++ b/ui/pages/bridge/index.tsx @@ -1,7 +1,6 @@ import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Switch, useHistory } from 'react-router-dom'; -import { zeroAddress } from 'ethereumjs-util'; import { I18nContext } from '../../contexts/i18n'; import { clearSwapsState } from '../../ducks/swaps/swaps'; import { @@ -30,12 +29,10 @@ import { Header, } from '../../components/multichain/pages/page'; import { useSwapsFeatureFlags } from '../swaps/hooks/useSwapsFeatureFlags'; -import { - resetBridgeState, - setFromChain, - setSrcTokenExchangeRates, -} from '../../ducks/bridge/actions'; +import { resetBridgeState, setFromChain } from '../../ducks/bridge/actions'; import { useGasFeeEstimates } from '../../hooks/useGasFeeEstimates'; +import { useBridgeExchangeRates } from '../../hooks/bridge/useBridgeExchangeRates'; +import { useQuoteFetchEvents } from '../../hooks/bridge/useQuoteFetchEvents'; import PrepareBridgePage from './prepare/prepare-bridge-page'; import { BridgeCTAButton } from './prepare/bridge-cta-button'; @@ -55,15 +52,8 @@ const CrossChainSwap = () => { const currency = useSelector(getCurrentCurrency); useEffect(() => { - if (isBridgeChain && isBridgeEnabled && providerConfig && currency) { + if (isBridgeChain && isBridgeEnabled && providerConfig) { dispatch(setFromChain(providerConfig.chainId)); - dispatch( - setSrcTokenExchangeRates({ - chainId: providerConfig.chainId, - tokenAddress: zeroAddress(), - currency, - }), - ); } }, [isBridgeChain, isBridgeEnabled, providerConfig, currency]); @@ -85,6 +75,10 @@ const CrossChainSwap = () => { // Needed for refreshing gas estimates useGasFeeEstimates(providerConfig?.id); + // Needed for fetching exchange rates for tokens that have not been imported + useBridgeExchangeRates(); + // Emits events related to quote-fetching + useQuoteFetchEvents(); const redirectToDefaultRoute = async () => { history.push({ diff --git a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap index 26e25b8bd4cd..a14e8ef5f05e 100644 --- a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap +++ b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap @@ -97,7 +97,7 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] = class="mm-box prepare-bridge-page__amounts-row" >

@@ -182,7 +182,7 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] = class="mm-box prepare-bridge-page__amounts-row" >

@@ -308,7 +308,7 @@ exports[`PrepareBridgePage should render the component, with inputs set 1`] = ` class="mm-box prepare-bridge-page__amounts-row" >

@@ -437,7 +437,7 @@ exports[`PrepareBridgePage should render the component, with inputs set 1`] = ` class="mm-box prepare-bridge-page__amounts-row" >

diff --git a/ui/pages/bridge/prepare/bridge-cta-button.tsx b/ui/pages/bridge/prepare/bridge-cta-button.tsx index b148b71c67f0..3a23d9735bca 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.tsx @@ -5,12 +5,19 @@ import { getFromAmount, getFromChain, getFromToken, - getToChain, getToToken, getBridgeQuotes, + getValidationErrors, } from '../../../ducks/bridge/selectors'; import { useI18nContext } from '../../../hooks/useI18nContext'; import useSubmitBridgeTransaction from '../hooks/useSubmitBridgeTransaction'; +import useLatestBalance from '../../../hooks/bridge/useLatestBalance'; +import { useIsTxSubmittable } from '../../../hooks/bridge/useIsTxSubmittable'; +import { useCrossChainSwapsEventTracker } from '../../../hooks/bridge/useCrossChainSwapsEventTracker'; +import { useRequestProperties } from '../../../hooks/bridge/events/useRequestProperties'; +import { useRequestMetadataProperties } from '../../../hooks/bridge/events/useRequestMetadataProperties'; +import { useTradeProperties } from '../../../hooks/bridge/events/useTradeProperties'; +import { MetaMetricsEventName } from '../../../../shared/constants/metametrics'; export const BridgeCTAButton = () => { const t = useI18nContext(); @@ -19,7 +26,6 @@ export const BridgeCTAButton = () => { const toToken = useSelector(getToToken); const fromChain = useSelector(getFromChain); - const toChain = useSelector(getToChain); const fromAmount = useSelector(getFromAmount); @@ -27,14 +33,30 @@ export const BridgeCTAButton = () => { const { submitBridgeTransaction } = useSubmitBridgeTransaction(); - const isTxSubmittable = - fromToken && toToken && fromChain && toChain && fromAmount && activeQuote; + const { isNoQuotesAvailable, isInsufficientBalance } = + useSelector(getValidationErrors); + + const { balanceAmount } = useLatestBalance(fromToken, fromChain?.chainId); + + const isTxSubmittable = useIsTxSubmittable(); + const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker(); + const { quoteRequestProperties } = useRequestProperties(); + const requestMetadataProperties = useRequestMetadataProperties(); + const tradeProperties = useTradeProperties(); const label = useMemo(() => { if (isLoading && !isTxSubmittable) { return t('swapFetchingQuotes'); } + if (isNoQuotesAvailable) { + return t('swapQuotesNotAvailableErrorTitle'); + } + + if (isInsufficientBalance(balanceAmount)) { + return t('alertReasonInsufficientBalance'); + } + if (!fromAmount) { if (!toToken) { return t('bridgeSelectTokenAndAmount'); @@ -47,13 +69,31 @@ export const BridgeCTAButton = () => { } return t('swapSelectToken'); - }, [isLoading, fromAmount, toToken, isTxSubmittable]); + }, [ + isLoading, + fromAmount, + toToken, + isTxSubmittable, + balanceAmount, + isInsufficientBalance, + ]); return (