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 (