@@ -239,131 +188,109 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] =
exports[`PrepareBridgePage should render the component, with inputs set 1`] = `
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
+
+
+ ETH
+
+
+
+
+
+
+
-
+ $0.00
-
-
- $5,805.77
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ Bridge to
+
+
+
+
+
+
+
-
- $0.00
-
+ Select token and amount
+
diff --git a/ui/pages/bridge/prepare/bridge-cta-button.test.tsx b/ui/pages/bridge/prepare/bridge-cta-button.test.tsx
index c4ff26f6c743..d4e0fd0855c5 100644
--- a/ui/pages/bridge/prepare/bridge-cta-button.test.tsx
+++ b/ui/pages/bridge/prepare/bridge-cta-button.test.tsx
@@ -22,7 +22,7 @@ describe('BridgeCTAButton', () => {
},
bridgeSliceOverrides: { fromTokenInputValue: 1 },
});
- const { container, getByText, getByRole } = renderWithProvider(
+ const { container, getByText } = renderWithProvider(
,
configureStore(mockStore),
);
@@ -30,7 +30,6 @@ describe('BridgeCTAButton', () => {
expect(container).toMatchSnapshot();
expect(getByText('Select token')).toBeInTheDocument();
- expect(getByRole('button')).toBeDisabled();
});
it('should render the component when amount is missing', () => {
@@ -54,13 +53,12 @@ describe('BridgeCTAButton', () => {
toChainId: CHAIN_IDS.LINEA_MAINNET,
},
});
- const { getByText, getByRole } = renderWithProvider(
+ const { getByText } = renderWithProvider(
,
configureStore(mockStore),
);
- expect(getByText('Enter amount')).toBeInTheDocument();
- expect(getByRole('button')).toBeDisabled();
+ expect(getByText('Select amount')).toBeInTheDocument();
});
it('should render the component when amount and dest token is missing', () => {
@@ -84,13 +82,13 @@ describe('BridgeCTAButton', () => {
toChainId: CHAIN_IDS.LINEA_MAINNET,
},
});
- const { getByText, getByRole } = renderWithProvider(
+ const { getByText, container } = renderWithProvider(
,
configureStore(mockStore),
);
expect(getByText('Select token and amount')).toBeInTheDocument();
- expect(getByRole('button')).toBeDisabled();
+ expect(container).toMatchSnapshot();
});
it('should render the component when tx is submittable', () => {
@@ -124,7 +122,7 @@ describe('BridgeCTAButton', () => {
configureStore(mockStore),
);
- expect(getByText('Confirm')).toBeInTheDocument();
+ expect(getByText('Submit')).toBeInTheDocument();
expect(getByRole('button')).not.toBeDisabled();
});
@@ -160,13 +158,12 @@ describe('BridgeCTAButton', () => {
quotesLoadingStatus: RequestStatus.LOADING,
},
});
- const { getByText, getByRole } = renderWithProvider(
+ const { container } = renderWithProvider(
,
configureStore(mockStore),
);
- expect(getByText('Fetching quotes...')).toBeInTheDocument();
- expect(getByRole('button')).toBeDisabled();
+ expect(container).toMatchSnapshot();
});
it('should enable the component when quotes are loading and there are existing quotes', () => {
@@ -201,12 +198,13 @@ describe('BridgeCTAButton', () => {
quotesLoadingStatus: RequestStatus.LOADING,
},
});
- const { getByText, getByRole } = renderWithProvider(
+ const { getByText, getByRole, container } = renderWithProvider(
,
configureStore(mockStore),
);
- expect(getByText('Confirm')).toBeInTheDocument();
+ expect(getByText('Submit')).toBeInTheDocument();
expect(getByRole('button')).not.toBeDisabled();
+ expect(container).toMatchSnapshot();
});
});
diff --git a/ui/pages/bridge/prepare/bridge-cta-button.tsx b/ui/pages/bridge/prepare/bridge-cta-button.tsx
index c8738a6551de..ea0892abaf8c 100644
--- a/ui/pages/bridge/prepare/bridge-cta-button.tsx
+++ b/ui/pages/bridge/prepare/bridge-cta-button.tsx
@@ -1,6 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
-import { Button } from '../../../components/component-library';
+import {
+ ButtonPrimary,
+ ButtonPrimarySize,
+ Text,
+} from '../../../components/component-library';
import {
getFromAmount,
getFromChain,
@@ -12,6 +16,12 @@ import {
} from '../../../ducks/bridge/selectors';
import { useI18nContext } from '../../../hooks/useI18nContext';
import useSubmitBridgeTransaction from '../hooks/useSubmitBridgeTransaction';
+import {
+ BlockSize,
+ TextAlign,
+ TextColor,
+ TextVariant,
+} from '../../../helpers/constants/design-system';
import useLatestBalance from '../../../hooks/bridge/useLatestBalance';
import { useIsTxSubmittable } from '../../../hooks/bridge/useIsTxSubmittable';
import { useCrossChainSwapsEventTracker } from '../../../hooks/bridge/useCrossChainSwapsEventTracker';
@@ -19,6 +29,8 @@ import { useRequestProperties } from '../../../hooks/bridge/events/useRequestPro
import { useRequestMetadataProperties } from '../../../hooks/bridge/events/useRequestMetadataProperties';
import { useTradeProperties } from '../../../hooks/bridge/events/useTradeProperties';
import { MetaMetricsEventName } from '../../../../shared/constants/metametrics';
+import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../../shared/constants/swaps';
+import { getNativeCurrency } from '../../../ducks/metamask/metamask';
export const BridgeCTAButton = () => {
const t = useI18nContext();
@@ -36,10 +48,22 @@ export const BridgeCTAButton = () => {
const { submitBridgeTransaction } = useSubmitBridgeTransaction();
- const { isNoQuotesAvailable, isInsufficientBalance } =
- useSelector(getValidationErrors);
+ const {
+ isNoQuotesAvailable,
+ isInsufficientBalance: isInsufficientBalance_,
+ isInsufficientGasBalance: isInsufficientGasBalance_,
+ isInsufficientGasForQuote: 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,
+ );
const isTxSubmittable = useIsTxSubmittable();
const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker();
@@ -47,7 +71,17 @@ export const BridgeCTAButton = () => {
const requestMetadataProperties = useRequestMetadataProperties();
const tradeProperties = useTradeProperties();
+ const ticker = useSelector(getNativeCurrency);
const [isQuoteExpired, setIsQuoteExpired] = useState(false);
+
+ const isInsufficientBalance = isInsufficientBalance_(balanceAmount);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const isInsufficientGasBalance =
+ isInsufficientGasBalance_(nativeAssetBalance);
+ const isInsufficientGasForQuote =
+ isInsufficientGasForQuote_(nativeAssetBalance);
+
useEffect(() => {
let timeout: NodeJS.Timeout;
// Reset the isQuoteExpired if quote fethching restarts
@@ -65,19 +99,19 @@ export const BridgeCTAButton = () => {
}, [isQuoteGoingToRefresh, quotesRefreshCount]);
const label = useMemo(() => {
- if (isQuoteExpired) {
+ if (isQuoteExpired && !isNoQuotesAvailable) {
return t('bridgeQuoteExpired');
}
- if (isLoading && !isTxSubmittable) {
- return t('swapFetchingQuotes');
+ if (isLoading && !isTxSubmittable && !activeQuote) {
+ return '';
}
- if (isNoQuotesAvailable) {
- return t('swapQuotesNotAvailableErrorTitle');
+ if (isInsufficientGasBalance || isNoQuotesAvailable) {
+ return '';
}
- if (isInsufficientBalance(balanceAmount)) {
+ if (isInsufficientBalance || isInsufficientGasForQuote) {
return t('alertReasonInsufficientBalance');
}
@@ -89,7 +123,7 @@ export const BridgeCTAButton = () => {
}
if (isTxSubmittable) {
- return t('confirm');
+ return t('submit');
}
return t('swapSelectToken');
@@ -97,17 +131,26 @@ export const BridgeCTAButton = () => {
isLoading,
fromAmount,
toToken,
+ ticker,
isTxSubmittable,
balanceAmount,
isInsufficientBalance,
isQuoteExpired,
+ isInsufficientGasBalance,
+ isInsufficientGasForQuote,
+ isQuoteExpired,
]);
- return (
-
{
- if (activeQuote && isTxSubmittable) {
+ if (activeQuote && isTxSubmittable && !isSubmitting) {
+ setIsSubmitting(true);
quoteRequestProperties &&
requestMetadataProperties &&
tradeProperties &&
@@ -122,9 +165,18 @@ export const BridgeCTAButton = () => {
submitBridgeTransaction(activeQuote);
}
}}
- disabled={!isTxSubmittable || isQuoteExpired}
+ disabled={!isTxSubmittable || isSubmitting || isQuoteExpired}
+ >
+ {label}
+
+ ) : (
+
{label}
-
+
);
};
diff --git a/ui/pages/bridge/prepare/bridge-input-group.tsx b/ui/pages/bridge/prepare/bridge-input-group.tsx
index 2f8ea8fda1c9..c7dac296025a 100644
--- a/ui/pages/bridge/prepare/bridge-input-group.tsx
+++ b/ui/pages/bridge/prepare/bridge-input-group.tsx
@@ -1,185 +1,271 @@
-import React from 'react';
-import { Hex } from '@metamask/utils';
+import React, { useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
-import { SwapsTokenObject } from '../../../../shared/constants/swaps';
+import { BigNumber } from 'bignumber.js';
+import { getAddress } from 'ethers/lib/utils';
import {
- Box,
Text,
TextField,
TextFieldType,
+ ButtonLink,
+ PopoverPosition,
+ Button,
+ ButtonSize,
} from '../../../components/component-library';
import { AssetPicker } from '../../../components/multichain/asset-picker-amount/asset-picker';
import { TabName } from '../../../components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-tabs';
-import CurrencyDisplay from '../../../components/ui/currency-display';
import { useI18nContext } from '../../../hooks/useI18nContext';
-import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount';
-import { useEthFiatAmount } from '../../../hooks/useEthFiatAmount';
-import { isSwapsDefaultTokenSymbol } from '../../../../shared/modules/swaps.utils';
-import Tooltip from '../../../components/ui/tooltip';
-import { SwapsEthToken } from '../../../selectors';
+import { getCurrentCurrency, getLocale } from '../../../selectors';
+import { formatCurrencyAmount, formatTokenAmount } from '../utils/quote';
+import { Column, Row, Tooltip } from '../layout';
import {
- ERC20Asset,
- NativeAsset,
-} from '../../../components/multichain/asset-picker-amount/asset-picker-modal/types';
-import { zeroAddress } from '../../../__mocks__/ethereumjs-util';
+ Display,
+ FontWeight,
+ TextAlign,
+ JustifyContent,
+ TextVariant,
+ TextColor,
+} from '../../../helpers/constants/design-system';
import { AssetType } from '../../../../shared/constants/transaction';
-import {
- CHAIN_ID_TO_CURRENCY_SYMBOL_MAP,
- CHAIN_ID_TOKEN_IMAGE_MAP,
-} from '../../../../shared/constants/network';
+import { BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE } from '../../../../shared/constants/bridge';
import useLatestBalance from '../../../hooks/bridge/useLatestBalance';
import {
getBridgeQuotes,
getValidationErrors,
} from '../../../ducks/bridge/selectors';
-import { TextColor } from '../../../helpers/constants/design-system';
-
-const generateAssetFromToken = (
- chainId: Hex,
- tokenDetails: SwapsTokenObject | SwapsEthToken,
-): ERC20Asset | NativeAsset => {
- if ('iconUrl' in tokenDetails && tokenDetails.address !== zeroAddress()) {
- return {
- type: AssetType.token,
- image: tokenDetails.iconUrl,
- symbol: tokenDetails.symbol,
- address: tokenDetails.address,
- chainId,
- };
- }
-
- return {
- type: AssetType.native,
- image:
- CHAIN_ID_TOKEN_IMAGE_MAP[
- chainId as keyof typeof CHAIN_ID_TOKEN_IMAGE_MAP
- ],
- symbol:
- CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[
- chainId as keyof typeof CHAIN_ID_TO_CURRENCY_SYMBOL_MAP
- ],
- chainId,
- };
-};
+import { shortenString } from '../../../helpers/utils/util';
+import { BridgeToken } from '../types';
+import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard';
+import { MINUTE } from '../../../../shared/constants/time';
+import { BridgeAssetPickerButton } from './components/bridge-asset-picker-button';
export const BridgeInputGroup = ({
- className,
header,
token,
onAssetChange,
onAmountChange,
networkProps,
+ isTokenListLoading,
customTokenListGenerator,
+ amountFieldProps,
+ amountInFiat,
+ onMaxButtonClick,
isMultiselectEnabled,
- amountFieldProps = {},
}: {
- className: string;
+ amountInFiat?: BigNumber;
onAmountChange?: (value: string) => void;
- token: SwapsTokenObject | SwapsEthToken | null;
- amountFieldProps?: Pick<
+ token: BridgeToken | null;
+ amountFieldProps: Pick<
React.ComponentProps
,
'testId' | 'autoFocus' | 'value' | 'readOnly' | 'disabled' | 'className'
>;
+ onMaxButtonClick?: (value: string) => void;
} & Pick<
React.ComponentProps,
| 'networkProps'
| 'header'
| 'customTokenListGenerator'
| 'onAssetChange'
+ | 'isTokenListLoading'
| 'isMultiselectEnabled'
>) => {
const t = useI18nContext();
- const { isLoading, activeQuote } = useSelector(getBridgeQuotes);
- const { isInsufficientBalance } = useSelector(getValidationErrors);
+ const { isLoading } = useSelector(getBridgeQuotes);
+ const { isInsufficientBalance, isEstimatedReturnLow } =
+ useSelector(getValidationErrors);
+ const currency = useSelector(getCurrentCurrency);
+ const locale = useSelector(getLocale);
- const tokenFiatValue = useTokenFiatAmount(
- token?.address || undefined,
- amountFieldProps?.value?.toString() || '0x0',
- token?.symbol,
- {
- showFiat: true,
- },
- true,
- );
- const ethFiatValue = useEthFiatAmount(
- amountFieldProps?.value?.toString() || '0x0',
- { showFiat: true },
- true,
- );
+ const selectedChainId = networkProps?.network?.chainId;
+ const { balanceAmount } = useLatestBalance(token, selectedChainId);
- const { formattedBalance, balanceAmount } = useLatestBalance(
- token,
- networkProps?.network?.chainId,
- );
+ const [, handleCopy] = useCopyToClipboard(MINUTE) as [
+ boolean,
+ (text: string) => void,
+ ];
+
+ const inputRef = useRef(null);
+
+ const [isLowReturnTooltipOpen, setIsLowReturnTooltipOpen] = useState(true);
+
+ useEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.value = amountFieldProps?.value?.toString() ?? '';
+ inputRef.current.focus();
+ }
+ }, [amountFieldProps]);
const isAmountReadOnly =
amountFieldProps?.readOnly || amountFieldProps?.disabled;
return (
-
-
+
+
+ ) => {
+ // Only allow numbers and at most one decimal point
+ if (
+ e &&
+ !/^[0-9]*\.{0,1}[0-9]*$/u.test(
+ `${amountFieldProps.value ?? ''}${e.key}`,
+ )
+ ) {
+ e.preventDefault();
+ }
+ }}
+ onChange={(e) => {
+ // Remove characters that are not numbers or decimal points if rendering a controlled or pasted value
+ const cleanedValue = e.target.value.replace(/[^0-9.]+/gu, '');
+ onAmountChange?.(cleanedValue);
+ }}
+ {...amountFieldProps}
+ />
-
-
+ isAmountReadOnly && !token ? (
+
+ {t('bridgeTo')}
+
+ ) : (
+
+ )
+ }
+
+
+
+
+
+ {isAmountReadOnly &&
+ isEstimatedReturnLow &&
+ isLowReturnTooltipOpen && (
+ setIsLowReturnTooltipOpen(false)}
+ triggerElement={ }
+ flip={false}
+ offset={[0, 80]}
+ >
+ {t('lowEstimatedReturnTooltipMessage', [
+ BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE * 100,
+ ])}
+
+ )}
+ {
- onAmountChange?.(e.target.value);
- }}
- {...amountFieldProps}
- />
-
-
-
+ textAlign={TextAlign.End}
+ ellipsis
+ >
+ {isAmountReadOnly && isLoading && amountFieldProps.value === '0'
+ ? t('bridgeCalculatingAmount')
+ : undefined}
+ {amountInFiat && formatCurrencyAmount(amountInFiat, currency, 2)}
+
+
{
+ if (isAmountReadOnly && token && selectedChainId) {
+ handleCopy(getAddress(token.address));
+ }
+ }}
+ as={isAmountReadOnly ? 'a' : 'p'}
>
- {formattedBalance ? `${t('balance')}: ${formattedBalance}` : ' '}
+ {isAmountReadOnly &&
+ token &&
+ selectedChainId &&
+ token.type === AssetType.token
+ ? shortenString(token.address, {
+ truncatedCharLimit: 11,
+ truncatedStartChars: 4,
+ truncatedEndChars: 4,
+ skipCharacterInEnd: false,
+ })
+ : undefined}
+ {!isAmountReadOnly && balanceAmount
+ ? formatTokenAmount(locale, balanceAmount, token?.symbol)
+ : undefined}
+ {onMaxButtonClick &&
+ token &&
+ token.type !== AssetType.native &&
+ balanceAmount && (
+ onMaxButtonClick(balanceAmount?.toFixed())}
+ >
+ {t('max')}
+
+ )}
-
-
-
+
+
);
};
diff --git a/ui/pages/bridge/prepare/bridge-transaction-settings-modal.stories.tsx b/ui/pages/bridge/prepare/bridge-transaction-settings-modal.stories.tsx
new file mode 100644
index 000000000000..9802ecce9e6f
--- /dev/null
+++ b/ui/pages/bridge/prepare/bridge-transaction-settings-modal.stories.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import configureStore from '../../../store/store';
+import { createBridgeMockStore } from '../../../../test/jest/mock-store';
+import { BridgeTransactionSettingsModal } from './bridge-transaction-settings-modal';
+
+const storybook = {
+ title: 'Pages/Bridge/TransactionSettingsModal',
+ component: BridgeTransactionSettingsModal,
+};
+
+export const DefaultStory = () => {
+ return {}} />;
+};
+
+DefaultStory.storyName = 'Default';
+DefaultStory.decorators = [
+ (Story) => (
+
+
+
+ ),
+];
+
+export default storybook;
diff --git a/ui/pages/bridge/prepare/bridge-transaction-settings-modal.tsx b/ui/pages/bridge/prepare/bridge-transaction-settings-modal.tsx
new file mode 100644
index 000000000000..8e03827103de
--- /dev/null
+++ b/ui/pages/bridge/prepare/bridge-transaction-settings-modal.tsx
@@ -0,0 +1,217 @@
+import React, { useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import {
+ Button,
+ ButtonPrimary,
+ ButtonPrimarySize,
+ ButtonSize,
+ ButtonVariant,
+ IconName,
+ Modal,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ PopoverPosition,
+ Text,
+ TextField,
+ TextFieldType,
+} from '../../../components/component-library';
+import { useI18nContext } from '../../../hooks/useI18nContext';
+import {
+ BackgroundColor,
+ BlockSize,
+ BorderColor,
+ BorderRadius,
+ JustifyContent,
+ TextColor,
+ TextVariant,
+} from '../../../helpers/constants/design-system';
+import { getSlippage } from '../../../ducks/bridge/selectors';
+import { setSlippage } from '../../../ducks/bridge/actions';
+import { useCrossChainSwapsEventTracker } from '../../../hooks/bridge/useCrossChainSwapsEventTracker';
+import { MetaMetricsEventName } from '../../../../shared/constants/metametrics';
+import { BRIDGE_DEFAULT_SLIPPAGE } from '../../../../shared/constants/bridge';
+import { Column, Row, Tooltip } from '../layout';
+
+const HARDCODED_SLIPPAGE_OPTIONS = [BRIDGE_DEFAULT_SLIPPAGE, 3];
+
+export const BridgeTransactionSettingsModal = ({
+ onClose,
+ isOpen,
+}: Omit, 'children'>) => {
+ const t = useI18nContext();
+ const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker();
+
+ const dispatch = useDispatch();
+
+ const slippage = useSelector(getSlippage);
+
+ const [localSlippage, setLocalSlippage] = useState(
+ slippage,
+ );
+ const [customSlippage, setCustomSlippage] = useState(
+ slippage && HARDCODED_SLIPPAGE_OPTIONS.includes(slippage)
+ ? undefined
+ : slippage,
+ );
+ const [showCustomButton, setShowCustomButton] = useState(true);
+
+ return (
+
+
+
+ {t('transactionSettings')}
+
+
+ {t('swapsMaxSlippage')}
+
+ {t('swapSlippageTooltip')}
+
+
+
+ {HARDCODED_SLIPPAGE_OPTIONS.map((hardcodedSlippage) => {
+ return (
+ ) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setLocalSlippage(hardcodedSlippage);
+ setCustomSlippage(undefined);
+ }}
+ variant={ButtonVariant.Secondary}
+ borderColor={
+ localSlippage === hardcodedSlippage && showCustomButton
+ ? BorderColor.primaryDefault
+ : BorderColor.borderDefault
+ }
+ borderWidth={
+ localSlippage === hardcodedSlippage && showCustomButton
+ ? 2
+ : 1
+ }
+ backgroundColor={
+ localSlippage === hardcodedSlippage && showCustomButton
+ ? BackgroundColor.primaryMuted
+ : BackgroundColor.backgroundDefault
+ }
+ >
+
+ {hardcodedSlippage}%
+
+
+ );
+ })}
+ {showCustomButton && (
+ ) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowCustomButton(false);
+ }}
+ >
+
+ {customSlippage === undefined
+ ? t('customSlippage')
+ : `${customSlippage}%`}
+
+
+ )}
+ {!showCustomButton && (
+ {
+ // Remove characters that are not numbers or decimal points if rendering a controlled or pasted value
+ const cleanedValue = e.target.value.replace(/[^0-9.]+/gu, '');
+ setLocalSlippage(undefined);
+ setCustomSlippage(
+ cleanedValue.length > 0 ? Number(cleanedValue) : undefined,
+ );
+ }}
+ autoFocus={true}
+ onBlur={() => {
+ console.log('====blur');
+ setShowCustomButton(true);
+ }}
+ onFocus={() => {
+ console.log('====focus');
+ setShowCustomButton(false);
+ }}
+ onKeyPress={(e?: React.KeyboardEvent) => {
+ // Only allow numbers and at most one decimal point
+ if (
+ e &&
+ !/^[0-9]*\.{0,1}[0-9]*$/u.test(
+ `${customSlippage ?? ''}${e.key}`,
+ )
+ ) {
+ e.preventDefault();
+ }
+ }}
+ endAccessory={% }
+ />
+ )}
+
+
+
+ {
+ const newSlippage = localSlippage || customSlippage;
+ newSlippage &&
+ trackCrossChainSwapsEvent({
+ event: MetaMetricsEventName.InputChanged,
+ properties: {
+ input: 'slippage',
+ value: newSlippage.toString(),
+ },
+ });
+ dispatch(setSlippage(newSlippage));
+ onClose();
+ }}
+ >
+ {t('submit')}
+
+
+
+
+ );
+};
diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx
new file mode 100644
index 000000000000..b04da2b981fa
--- /dev/null
+++ b/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import {
+ SelectButtonProps,
+ SelectButtonSize,
+} from '../../../../components/component-library/select-button/select-button.types';
+import {
+ AvatarNetwork,
+ AvatarNetworkSize,
+ AvatarToken,
+ BadgeWrapper,
+ IconName,
+ SelectButton,
+ Text,
+} from '../../../../components/component-library';
+import {
+ AlignItems,
+ BackgroundColor,
+ BorderColor,
+ BorderRadius,
+ Display,
+ OverflowWrap,
+ TextVariant,
+} from '../../../../helpers/constants/design-system';
+import { useI18nContext } from '../../../../hooks/useI18nContext';
+import { AssetPicker } from '../../../../components/multichain/asset-picker-amount/asset-picker';
+
+export const BridgeAssetPickerButton = ({
+ asset,
+ networkProps,
+ networkImageSrc,
+ ...props
+}: {
+ networkImageSrc?: string;
+} & SelectButtonProps<'div'> &
+ Pick, 'asset' | 'networkProps'>) => {
+ const t = useI18nContext();
+
+ return (
+
+ {asset?.symbol ?? t('bridgeTo')}
+
+ }
+ startAccessory={
+ asset ? (
+
+ ) : undefined
+ }
+ >
+ {asset ? (
+
+ ) : undefined}
+
+ ) : undefined
+ }
+ {...props}
+ />
+ );
+};
diff --git a/ui/pages/bridge/prepare/index.scss b/ui/pages/bridge/prepare/index.scss
index 079c057c59de..cfefc86e52e0 100644
--- a/ui/pages/bridge/prepare/index.scss
+++ b/ui/pages/bridge/prepare/index.scss
@@ -1,144 +1,38 @@
@use "design-system";
.prepare-bridge-page {
- display: flex;
- flex-flow: column;
flex: 1;
- width: 100%;
- gap: 24px;
- &__content {
- display: flex;
- flex-direction: column;
- padding: 16px 0 16px 0;
- border-radius: 8px;
- border: 1px solid var(--color-border-muted);
- }
-
- .bridge-box {
- display: flex;
- flex-direction: column;
- gap: 4px;
- justify-content: center;
- padding: 8px 16px 8px 16px;
- }
-
- &__input-row {
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- width: 298px;
- gap: 16px;
-
- input[type="number"]::-webkit-inner-spin-button {
- -webkit-appearance: none;
- -moz-appearance: none;
- display: none;
- }
-
- input[type="number"]:hover::-webkit-inner-spin-button {
- -webkit-appearance: none;
- -moz-appearance: none;
- display: none;
- }
-
- .mm-text-field {
- background-color: inherit;
-
- &--focused {
- outline: none;
- }
- }
-
- .defined {
- opacity: 1;
-
- & > .mm-input--disabled {
- opacity: 1;
- }
- }
- }
-
- .amount-input {
- border: none;
-
- input {
- text-align: right;
- padding-right: 0;
- font-size: 24px;
- font-weight: 700;
-
- &:focus,
- &:focus-visible {
- outline: none;
- }
- }
+ .mm-text-field {
+ background-color: inherit;
- .mm-text-field--focused {
+ &--focused {
outline: none;
}
}
- &__amounts-row {
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- width: 298px;
- height: 22px;
-
- p,
- span {
- color: var(--color-text-alternative);
- font-size: 12px;
- }
- }
-
- .asset-picker {
- border: 1px solid var(--color-border-muted);
- height: 40px;
- min-width: fit-content;
- max-width: fit-content;
- background-color: inherit;
-
+ .defined {
+ & > .mm-input--disabled,
p {
- font-size: 14px;
- font-weight: 500;
+ opacity: 1;
}
+ }
- .mm-avatar-token {
- height: 24px;
- width: 24px;
- border: 1px solid var(--color-border-muted);
- }
+ .mm-select-button__content {
+ max-height: 100%;
+ overflow: hidden;
+ }
- .mm-badge-wrapper__badge-container .mm-avatar-base {
- height: 10px;
- width: 10px;
- border: none;
- }
+ .amount-input {
+ border: none;
+ padding: 0;
+ width: 100%;
+ gap: 4px;
+ height: fit-content;
}
&__switch-tokens {
- display: flex;
- justify-content: center;
- align-items: center;
-
- &::before,
- &::after {
- content: '';
- border-top: 1px solid var(--color-border-muted);
- flex-grow: 1;
- }
-
button {
- border-radius: 50%;
- padding: 10px;
- border: 1px solid var(--color-border-muted);
- transition: all 0.3s ease-in-out;
- cursor: pointer;
- width: 40px;
- height: 40px;
-
&:hover:enabled {
background: var(--color-background-default-hover);
@@ -161,12 +55,54 @@
}
.mm-icon {
- color: var(--color-icon-alternative);
transition: all 0.3s ease-in-out;
}
+ }
+
+ .mascot-background-animation__animation {
+ margin-top: 24px;
+ margin-bottom: 16px;
+ }
+
+ .highlight {
+ padding: 16px;
+ background: var(--color-background-default);
+ border: none;
+ border-radius: 8px;
+
+ [data-theme='light'],
+ .light {
+ box-shadow: 0 0 2px 0 #e2e4e9, 0 0 16px 0 rgba(226, 228, 233, 0.16);
+ }
+
+ [data-theme='dark'],
+ .dark {
+ box-shadow: 0 0 2px 0 #18191b, 0 0 16px 0 #18191b;
+ }
+ }
+}
+
+.bridge-settings-modal {
+ .mm-button-secondary {
+ &:hover {
+ background-color: var(--color-background-default-hover);
+ }
+ }
+
+ .mm-text-field {
+ height: 32px;
+ width: 94px;
+
+ &--focused,
+ &:focus-visible {
+ outline: none;
+ }
- &:disabled {
- cursor: not-allowed;
+ input {
+ font-size: var(--font-size-2);
+ padding-top: 1px;
+ width: 100%;
+ height: 32px;
}
}
}
diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.stories.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.stories.tsx
new file mode 100644
index 000000000000..3e955d3e34ef
--- /dev/null
+++ b/ui/pages/bridge/prepare/prepare-bridge-page.stories.tsx
@@ -0,0 +1,248 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import configureStore from '../../../store/store';
+import { createBridgeMockStore } from '../../../../test/jest/mock-store';
+import { CHAIN_IDS } from '../../../../shared/constants/network';
+import { RequestStatus } from '../../../../app/scripts/controllers/bridge/constants';
+import CrossChainSwap from '../index';
+import { MemoryRouter } from 'react-router-dom';
+import {
+ CROSS_CHAIN_SWAP_ROUTE,
+ PREPARE_SWAP_ROUTE,
+} from '../../../helpers/constants/routes';
+import mockBridgeQuotesErc20Erc20 from '../../../../test/data/bridge/mock-quotes-erc20-erc20.json';
+
+const storybook = {
+ title: 'Pages/Bridge/CrossChainSwapPage',
+ component: CrossChainSwap,
+};
+
+const Wrapper = ({ children }) => (
+
+
+ {children}
+
+
+);
+
+const mockFeatureFlags = {
+ srcNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.LINEA_MAINNET],
+ destNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.LINEA_MAINNET],
+ extensionSupport: true,
+ extensionConfig: {
+ refreshRate: 30000,
+ maxRefreshCount: 5,
+ },
+};
+const mockBridgeSlice = {
+ toChainId: CHAIN_IDS.LINEA_MAINNET,
+ toNativeExchangeRate: 1,
+ toTokenExchangeRate: 0.99,
+ fromTokenInputValue: '1',
+};
+export const DefaultStory = () => {
+ return ;
+};
+DefaultStory.storyName = 'Default';
+DefaultStory.decorators = [
+ (Story) => (
+
+
+
+
+
+ ),
+];
+
+export const LoadingStory = () => {
+ return ;
+};
+LoadingStory.storyName = 'Loading Quotes';
+LoadingStory.decorators = [
+ (Story) => (
+
+
+
+
+
+ ),
+];
+
+export const NoQuotesStory = () => {
+ return ;
+};
+NoQuotesStory.storyName = 'No Quotes';
+NoQuotesStory.decorators = [
+ (Story) => (
+
+
+
+
+
+ ),
+];
+
+export const QuotesFetchedStory = () => {
+ return ;
+};
+QuotesFetchedStory.storyName = 'Quotes Available';
+QuotesFetchedStory.decorators = [
+ (Story) => (
+
+
+
+
+
+ ),
+];
+
+export default storybook;
diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx
index 6803deb301aa..de8fdfc25b36 100644
--- a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx
+++ b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { act } from '@testing-library/react';
import * as reactRouterUtils from 'react-router-dom-v5-compat';
+import { zeroAddress } from 'ethereumjs-util';
import { fireEvent, renderWithProvider } from '../../../../test/jest';
import configureStore from '../../../store/store';
import { createBridgeMockStore } from '../../../../test/jest/mock-store';
@@ -42,6 +43,34 @@ describe('PrepareBridgePage', () => {
},
},
},
+ metamaskStateOverrides: {
+ completedOnboarding: true,
+ allDetectedTokens: {
+ '0x1': {
+ '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [
+ {
+ address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984',
+ decimals: 6,
+ }, // USDC
+ ],
+ },
+ },
+ },
+ bridgeStateOverrides: {
+ srcTokens: {
+ '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2': {
+ address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2',
+ }, // UNI,
+ [zeroAddress()]: { address: zeroAddress() },
+ '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': {
+ address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984',
+ decimals: 6,
+ }, // USDC
+ },
+ srcTopAssets: [
+ { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984' },
+ ],
+ },
});
const { container, getByRole, getByTestId } = renderWithProvider(
,
@@ -57,7 +86,7 @@ describe('PrepareBridgePage', () => {
await act(() => {
fireEvent.change(getByTestId('from-amount'), { target: { value: '2' } });
});
- expect(getByTestId('from-amount').closest('input')).toHaveValue(2);
+ expect(getByTestId('from-amount').closest('input')).toHaveValue('2');
expect(getByTestId('to-amount')).toBeInTheDocument();
expect(getByTestId('to-amount').closest('input')).toBeDisabled();
@@ -126,21 +155,25 @@ describe('PrepareBridgePage', () => {
expect(container).toMatchSnapshot();
expect(getByRole('button', { name: /ETH/u })).toBeInTheDocument();
- expect(getByRole('button', { name: /UNI/u })).toBeInTheDocument();
+ expect(getByRole('button', { name: /Bridge to/u })).toBeInTheDocument();
expect(getByTestId('from-amount')).toBeInTheDocument();
expect(getByTestId('from-amount').closest('input')).not.toBeDisabled();
- expect(getByTestId('from-amount').closest('input')).toHaveValue(1);
+
+ await act(() => {
+ fireEvent.change(getByTestId('from-amount'), { target: { value: '1' } });
+ });
+ expect(getByTestId('from-amount').closest('input')).toHaveValue('1');
await act(() => {
fireEvent.change(getByTestId('from-amount'), { target: { value: '2' } });
});
- expect(getByTestId('from-amount').closest('input')).toHaveValue(2);
+ expect(getByTestId('from-amount').closest('input')).toHaveValue('2');
expect(getByTestId('to-amount')).toBeInTheDocument();
expect(getByTestId('to-amount').closest('input')).toBeDisabled();
- expect(getByTestId('switch-tokens').closest('button')).not.toBeDisabled();
+ expect(getByTestId('switch-tokens').closest('button')).toBeDisabled();
});
it('should throw an error if token decimals are not defined', async () => {
diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx
index 1533fc1a9c20..1433c627af21 100644
--- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx
+++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx
@@ -1,8 +1,15 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { useSelector, useDispatch } from 'react-redux';
import classnames from 'classnames';
import { debounce } from 'lodash';
import { useHistory, useLocation } from 'react-router-dom';
+import { BigNumber } from 'bignumber.js';
import {
setFromChain,
setFromToken,
@@ -12,6 +19,7 @@ import {
setToChainId,
setToToken,
updateQuoteRequestParams,
+ resetBridgeState,
} from '../../../ducks/bridge/actions';
import {
getBridgeQuotes,
@@ -20,29 +28,45 @@ import {
getFromChains,
getFromToken,
getFromTokens,
- getFromTopAssets,
getQuoteRequest,
+ getSlippage,
getToChain,
getToChains,
getToToken,
getToTokens,
- getToTopAssets,
+ getFromAmountInCurrency,
+ getValidationErrors,
+ getBridgeQuotesConfig,
} from '../../../ducks/bridge/selectors';
import {
+ BannerAlert,
+ BannerAlertSeverity,
Box,
ButtonIcon,
IconName,
+ PopoverPosition,
+ Text,
} from '../../../components/component-library';
-import { BlockSize } from '../../../helpers/constants/design-system';
+import {
+ BackgroundColor,
+ BlockSize,
+ Display,
+ FlexDirection,
+ IconColor,
+ JustifyContent,
+ TextAlign,
+ TextColor,
+ TextVariant,
+} from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
-import { TokenBucketPriority } from '../../../../shared/constants/swaps';
-import { useTokensWithFiltering } from '../../../hooks/useTokensWithFiltering';
+import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../../shared/constants/swaps';
+import { useTokensWithFiltering } from '../../../hooks/bridge/useTokensWithFiltering';
import { setActiveNetwork } from '../../../store/actions';
import { hexToDecimal } from '../../../../shared/modules/conversion.utils';
import { QuoteRequest } from '../types';
import { calcTokenValue } from '../../../../shared/lib/swaps-utils';
import { BridgeQuoteCard } from '../quotes/bridge-quote-card';
-import { isValidQuoteRequest } from '../utils/quote';
+import { formatTokenAmount, isValidQuoteRequest } from '../utils/quote';
import { getProviderConfig } from '../../../../shared/modules/selectors/networks';
import {
CrossChainSwapsEventProperties,
@@ -51,7 +75,19 @@ import {
import { useRequestProperties } from '../../../hooks/bridge/events/useRequestProperties';
import { MetaMetricsEventName } from '../../../../shared/constants/metametrics';
import { isNetworkAdded } from '../../../ducks/bridge/utils';
+import { Footer } from '../../../components/multichain/pages/page';
+import MascotBackgroundAnimation from '../../swaps/mascot-background-animation/mascot-background-animation';
+import { Column, Row, Tooltip } from '../layout';
+import useRamps from '../../../hooks/ramps/useRamps/useRamps';
+import { getNativeCurrency } from '../../../ducks/metamask/metamask';
+import useLatestBalance from '../../../hooks/bridge/useLatestBalance';
+import { useCountdownTimer } from '../../../hooks/bridge/useCountdownTimer';
+import { useBridgeTokens } from '../../../hooks/bridge/useBridgeTokens';
+import { getCurrentKeyring, getLocale } from '../../../selectors';
+import { isHardwareKeyring } from '../../../helpers/utils/hardware';
+import { SECOND } from '../../../../shared/constants/time';
import { BridgeInputGroup } from './bridge-input-group';
+import { BridgeCTAButton } from './bridge-cta-button';
const PrepareBridgePage = () => {
const dispatch = useDispatch();
@@ -59,12 +95,18 @@ const PrepareBridgePage = () => {
const t = useI18nContext();
const fromToken = useSelector(getFromToken);
- const fromTokens = useSelector(getFromTokens);
- const fromTopAssets = useSelector(getFromTopAssets);
+ const {
+ fromTokens,
+ fromTopAssets,
+ isLoading: isFromTokensLoading,
+ } = useSelector(getFromTokens);
const toToken = useSelector(getToToken);
- const toTokens = useSelector(getToTokens);
- const toTopAssets = useSelector(getToTopAssets);
+ const {
+ toTokens,
+ toTopAssets,
+ isLoading: isToTokensLoading,
+ } = useSelector(getToTokens);
const fromChains = useSelector(getFromChains);
const toChains = useSelector(getToChains);
@@ -72,37 +114,106 @@ const PrepareBridgePage = () => {
const toChain = useSelector(getToChain);
const fromAmount = useSelector(getFromAmount);
+ const fromAmountInFiat = useSelector(getFromAmountInCurrency);
const providerConfig = useSelector(getProviderConfig);
+ const slippage = useSelector(getSlippage);
const quoteRequest = useSelector(getQuoteRequest);
- const { activeQuote } = useSelector(getBridgeQuotes);
+ const { isLoading, activeQuote, isQuoteGoingToRefresh } =
+ useSelector(getBridgeQuotes);
+
+ const { refreshRate } = useSelector(getBridgeQuotesConfig);
+
+ const keyring = useSelector(getCurrentKeyring);
+ // @ts-expect-error keyring type is wrong maybe?
+ const isUsingHardwareWallet = isHardwareKeyring(keyring.type);
+ const locale = useSelector(getLocale);
+
+ const ticker = useSelector(getNativeCurrency);
+ const {
+ isNoQuotesAvailable,
+ isInsufficientGasForQuote,
+ isInsufficientBalance,
+ } = useSelector(getValidationErrors);
+ const { openBuyCryptoInPdapp } = useRamps();
+ const { balanceAmount: nativeAssetBalance } = useLatestBalance(
+ SWAPS_CHAINID_DEFAULT_TOKEN_MAP[
+ fromChain?.chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP
+ ],
+ fromChain?.chainId,
+ );
+
+ const { balanceAmount: srcTokenBalance } = useLatestBalance(
+ fromToken,
+ fromChain?.chainId,
+ );
+
+ const tokenAddressAllowlistByChainId = useBridgeTokens();
const fromTokenListGenerator = useTokensWithFiltering(
fromTokens,
fromTopAssets,
- TokenBucketPriority.owned,
+ tokenAddressAllowlistByChainId,
fromChain?.chainId,
);
const toTokenListGenerator = useTokensWithFiltering(
toTokens,
toTopAssets,
- TokenBucketPriority.top,
+ tokenAddressAllowlistByChainId,
toChain?.chainId,
);
const { flippedRequestProperties } = useRequestProperties();
const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker();
+ const millisecondsUntilNextRefresh = useCountdownTimer();
+
const [rotateSwitchTokens, setRotateSwitchTokens] = useState(false);
+ // Background updates are debounced when the switch button is clicked
+ // To prevent putting the frontend in an unexpected state, prevent the user
+ // from switching tokens within the debounce period
+ const [isSwitchingTemporarilyDisabled, setIsSwitchingTemporarilyDisabled] =
+ useState(false);
+ useEffect(() => {
+ setIsSwitchingTemporarilyDisabled(true);
+ const switchButtonTimer = setTimeout(() => {
+ setIsSwitchingTemporarilyDisabled(false);
+ }, SECOND);
+
+ return () => {
+ clearTimeout(switchButtonTimer);
+ };
+ }, [rotateSwitchTokens]);
+
+ useEffect(() => {
+ // Reset controller and inputs on load
+ dispatch(resetBridgeState());
+ }, []);
+
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ if (isInsufficientGasForQuote(nativeAssetBalance)) {
+ scrollRef.current?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ }
+ }, [isInsufficientGasForQuote(nativeAssetBalance)]);
+
const quoteParams = useMemo(
() => ({
srcTokenAddress: fromToken?.address,
destTokenAddress: toToken?.address || undefined,
srcTokenAmount:
- fromAmount && fromAmount !== '' && fromToken?.decimals
- ? calcTokenValue(fromAmount, fromToken.decimals).toString()
+ fromAmount && fromToken?.decimals
+ ? calcTokenValue(
+ // Treat empty or incomplete amount as 0 to reject NaN
+ ['', '.'].includes(fromAmount) ? '0' : fromAmount,
+ fromToken.decimals,
+ ).toFixed()
: undefined,
srcChainId: fromChain?.chainId
? Number(hexToDecimal(fromChain.chainId))
@@ -114,6 +225,7 @@ const PrepareBridgePage = () => {
// Otherwise quotes get filtered out by the bridge-api when the wallet's real
// balance is less than the tenderly balance
insufficientBal: Boolean(providerConfig?.rpcUrl?.includes('tenderly')),
+ slippage,
}),
[
fromToken,
@@ -122,6 +234,7 @@ const PrepareBridgePage = () => {
toChain?.chainId,
fromAmount,
providerConfig,
+ slippage,
],
);
@@ -179,7 +292,9 @@ const PrepareBridgePage = () => {
case fromTokens[tokenAddressFromUrl]?.address?.toLowerCase(): {
// If there is a matching fromToken, set it as the fromToken
const matchedToken = fromTokens[tokenAddressFromUrl];
- dispatch(setFromToken(matchedToken));
+ dispatch(
+ setFromToken({ ...matchedToken, image: matchedToken.iconUrl }),
+ );
removeTokenFromUrl();
break;
}
@@ -191,74 +306,117 @@ const PrepareBridgePage = () => {
}, [fromChain, fromToken, fromTokens, search]);
return (
-
-
- {
- dispatch(setFromTokenInputValue(e));
- }}
- onAssetChange={(token) => {
- token?.address &&
- trackInputEvent({
- input: 'token_source',
- value: token.address,
- });
- dispatch(setFromToken(token));
- dispatch(setFromTokenInputValue(null));
- }}
- networkProps={{
- network: fromChain,
- networks: fromChains,
- onNetworkChange: (networkConfig) => {
+
+ {
+ dispatch(setFromTokenInputValue(e));
+ }}
+ onAssetChange={(token) => {
+ dispatch(setFromToken(token));
+ dispatch(setFromTokenInputValue(null));
+ token?.address &&
+ trackInputEvent({
+ input: 'token_source',
+ value: token.address,
+ });
+ dispatch(setFromToken(token));
+ dispatch(setFromTokenInputValue(null));
+ }}
+ networkProps={{
+ network: fromChain,
+ networks: fromChains,
+ onNetworkChange: (networkConfig) => {
+ networkConfig.chainId !== fromChain?.chainId &&
trackInputEvent({
input: 'chain_source',
value: networkConfig.chainId,
});
- if (networkConfig.chainId === toChain?.chainId) {
- dispatch(setToChainId(null));
- }
- if (isNetworkAdded(networkConfig)) {
- dispatch(
- setActiveNetwork(
- networkConfig.rpcEndpoints[
- networkConfig.defaultRpcEndpointIndex
- ].networkClientId,
- ),
- );
- }
- dispatch(setFromChain(networkConfig.chainId));
- dispatch(setFromToken(null));
- dispatch(setFromTokenInputValue(null));
- },
- header: t('bridgeFrom'),
- }}
- customTokenListGenerator={
- fromTokens && fromTopAssets ? fromTokenListGenerator : undefined
- }
- amountFieldProps={{
- testId: 'from-amount',
- autoFocus: true,
- value: fromAmount || undefined,
- }}
- isMultiselectEnabled={true}
- />
+ if (networkConfig.chainId === toChain?.chainId) {
+ dispatch(setToChainId(null));
+ dispatch(setToToken(null));
+ }
+ if (isNetworkAdded(networkConfig)) {
+ dispatch(
+ setActiveNetwork(
+ networkConfig.rpcEndpoints[
+ networkConfig.defaultRpcEndpointIndex
+ ].networkClientId,
+ ),
+ );
+ }
+ dispatch(setFromChain(networkConfig.chainId));
+ dispatch(setFromToken(null));
+ dispatch(setFromTokenInputValue(null));
+ },
+ header: t('yourNetworks'),
+ }}
+ isMultiselectEnabled
+ customTokenListGenerator={
+ fromTokens && fromTopAssets ? fromTokenListGenerator : undefined
+ }
+ onMaxButtonClick={(value: string) => {
+ dispatch(setFromTokenInputValue(value));
+ }}
+ amountInFiat={fromAmountInFiat}
+ amountFieldProps={{
+ testId: 'from-amount',
+ autoFocus: true,
+ value: fromAmount || undefined,
+ }}
+ isTokenListLoading={isFromTokensLoading}
+ />
-
+
+
{
+ if (!isNetworkAdded(toChain)) {
+ return;
+ }
setRotateSwitchTokens(!rotateSwitchTokens);
flippedRequestProperties &&
trackCrossChainSwapsEvent({
@@ -283,7 +441,6 @@ const PrepareBridgePage = () => {
{
@@ -298,33 +455,153 @@ const PrepareBridgePage = () => {
network: toChain,
networks: toChains,
onNetworkChange: (networkConfig) => {
- trackInputEvent({
- input: 'chain_destination',
- value: networkConfig.chainId,
- });
+ networkConfig.chainId !== toChain?.chainId &&
+ trackInputEvent({
+ input: 'chain_destination',
+ value: networkConfig.chainId,
+ });
dispatch(setToChainId(networkConfig.chainId));
dispatch(setToChain(networkConfig.chainId));
+ dispatch(setToToken(null));
},
header: t('bridgeTo'),
+ shouldDisableNetwork: ({ chainId }) =>
+ chainId === fromChain?.chainId,
}}
customTokenListGenerator={
toChain && toTokens && toTopAssets
? toTokenListGenerator
- : fromTokenListGenerator
+ : undefined
+ }
+ amountInFiat={
+ activeQuote?.toTokenAmount?.valueInCurrency || undefined
}
amountFieldProps={{
testId: 'to-amount',
readOnly: true,
disabled: true,
- value: activeQuote?.toTokenAmount?.amount.toFixed() ?? '0',
- className: activeQuote?.toTokenAmount.amount
+ value: activeQuote?.toTokenAmount?.amount
+ ? formatTokenAmount(locale, activeQuote.toTokenAmount.amount)
+ : '0',
+ autoFocus: false,
+ className: activeQuote?.toTokenAmount?.amount
? 'amount-input defined'
: 'amount-input',
}}
+ isTokenListLoading={isToTokensLoading}
/>
-
-
-
+
+ {isLoading && !activeQuote ? (
+ <>
+
+ {t('swapFetchingQuotes')}
+
+
+ >
+ ) : null}
+
+
+
+
+ {activeQuote && isQuoteGoingToRefresh && (
+
+ )}
+
+
+
+ {activeQuote?.approval && fromAmount && fromToken ? (
+
+
+ {isUsingHardwareWallet
+ ? t('willApproveAmountForBridgingHardware')
+ : t('willApproveAmountForBridging', [
+ formatTokenAmount(
+ locale,
+ new BigNumber(fromAmount),
+ fromToken.symbol,
+ ),
+ ])}
+
+ {fromAmount && (
+
+ {isUsingHardwareWallet
+ ? t('bridgeApprovalWarningForHardware', [
+ fromAmount,
+ fromToken.symbol,
+ ])
+ : t('bridgeApprovalWarning', [
+ fromAmount,
+ fromToken.symbol,
+ ])}
+
+ )}
+
+ ) : null}
+
+
+
+ {isNoQuotesAvailable && (
+
+ )}
+ {!isLoading &&
+ activeQuote &&
+ !isInsufficientBalance(srcTokenBalance) &&
+ isInsufficientGasForQuote(nativeAssetBalance) && (
+ openBuyCryptoInPdapp()}
+ />
+ )}
+
+
);
};
diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap
index 6b69b8ec9a6c..4b0b41df569d 100644
--- a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap
+++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap
@@ -7,177 +7,158 @@ exports[`BridgeQuoteCard should not render when there is no quote 1`] = `
exports[`BridgeQuoteCard should render the recommended quote 1`] = `
-
- New quotes in 0:30
-
-
-
+
+
+
+ Bridging
+
+
+
+
- Quote rate
+ OP Mainnet
-
-
+
- 1 USDC = 1.00 USDC
+ Polygon
+
+ Network fees
+
-
-
+ ·
+
- $2.52
+ 0.001 ETH
-
-
@@ -186,171 +167,158 @@ Fees are based on network traffic and transaction complexity. MetaMask does not
exports[`BridgeQuoteCard should render the recommended quote while loading new quotes 1`] = `
-
+
+
+
+ Bridging
+
+
+
+
- Quote rate
+ OP Mainnet
-
-
+
- 1 ETH = 2443.89 USDC
+ Polygon
+
+ Network fees
+
-
-
+ ·
+
- $2.52
+ 0.001 ETH
-
-
diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap
index 137dc246864e..2c401feffa78 100644
--- a/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap
+++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quotes-modal.test.tsx.snap
@@ -66,7 +66,7 @@ exports[`BridgeQuotesModal should render the modal 1`] = `
class="mm-box mm-text mm-text--inherit mm-box--color-primary-default"
>
Net cost
@@ -77,13 +77,13 @@ exports[`BridgeQuotesModal should render the modal 1`] = `
/>
Time
@@ -96,11 +96,11 @@ exports[`BridgeQuotesModal should render the modal 1`] = `
>
- $3 network fee
+ $14 total cost
- 14 USDC receive amount
+ 13.98 USDC receive amount
- $3 network fee
+ $14 total cost
- 14 USDC receive amount
+ 13.8 USDC receive amount
{
const t = useI18nContext();
- const { isLoading, isQuoteGoingToRefresh, activeQuote } =
- useSelector(getBridgeQuotes);
+ const { activeQuote } = useSelector(getBridgeQuotes);
const currency = useSelector(getCurrentCurrency);
const ticker = useSelector(getNativeCurrency);
- const secondsUntilNextRefresh = useCountdownTimer();
-
const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker();
const { quoteRequestProperties } = useRequestProperties();
const requestMetadataProperties = useRequestMetadataProperties();
const quoteListProperties = useQuoteProperties();
- const [showAllQuotes, setShowAllQuotes] = useState(false);
+ const fromChain = useSelector(getFromChain);
+ const toChain = useSelector(getToChain);
+ const locale = useSelector(getLocale);
- if (isLoading && !activeQuote) {
- return (
-
-
-
- );
- }
+ const [showAllQuotes, setShowAllQuotes] = useState(false);
- return activeQuote ? (
-
+ return (
+ <>
setShowAllQuotes(false)}
/>
-
- {!isLoading && isQuoteGoingToRefresh && (
- {t('swapNewQuoteIn', [secondsUntilNextRefresh])}
- )}
-
-
-
-
- {activeQuote.swapRate && (
-
- )}
- {activeQuote.totalNetworkFee && (
-
- )}
-
+ {activeQuote ? (
+
+
+
+ {t('bestPrice')}
+
+ {t('howQuotesWorkExplanation', [BRIDGE_MM_FEE_RATE])}
+
+
+
+ {
+ quoteRequestProperties &&
+ requestMetadataProperties &&
+ quoteListProperties &&
+ trackCrossChainSwapsEvent({
+ event: MetaMetricsEventName.AllQuotesOpened,
+ properties: {
+ ...quoteRequestProperties,
+ ...requestMetadataProperties,
+ ...quoteListProperties,
+ },
+ });
+ setShowAllQuotes(true);
+ }}
+ >
+ {t('moreQuotes')}
+
+
+
+
+
+
+ {t('bridging')}
+
+
+
+
+ {
+ NETWORK_TO_SHORT_NETWORK_NAME_MAP[
+ decimalToPrefixedHex(
+ activeQuote.quote.srcChainId,
+ ) as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP
+ ]
+ }
+
+
+
+
+ {
+ NETWORK_TO_SHORT_NETWORK_NAME_MAP[
+ decimalToPrefixedHex(
+ activeQuote.quote.destChainId,
+ ) as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP
+ ]
+ }
+
+
+
-
-
- {t('swapIncludesMMFee', [0.875])}
- {
- quoteRequestProperties &&
- requestMetadataProperties &&
- quoteListProperties &&
- trackCrossChainSwapsEvent({
- event: MetaMetricsEventName.AllQuotesOpened,
- properties: {
- ...quoteRequestProperties,
- ...requestMetadataProperties,
- ...quoteListProperties,
- },
- });
- setShowAllQuotes(true);
- }}
- >
- {t('viewAllQuotes')}
-
-
-
- {t('termsOfService')}
-
-
-
- ) : null;
+
+
+ {t('networkFees')}
+
+
+
+ {formatCurrencyAmount(
+ activeQuote.totalNetworkFee?.valueInCurrency,
+ currency,
+ 2,
+ ) ??
+ formatTokenAmount(
+ locale,
+ activeQuote.totalNetworkFee?.amount,
+ ticker,
+ )}
+
+
+ {t('bulletpoint')}
+
+
+ {activeQuote.totalNetworkFee?.valueInCurrency
+ ? formatTokenAmount(
+ locale,
+ activeQuote.totalNetworkFee?.amount,
+ ticker,
+ )
+ : undefined}
+
+
+
+
+
+ {t('time')}
+
+
+ {t('bridgeTimingMinutes', [
+ formatEtaInMinutes(
+ activeQuote.estimatedProcessingTimeInSeconds,
+ ),
+ ])}
+
+
+
+
+ {t('rateIncludesMMFee', [BRIDGE_MM_FEE_RATE])}
+
+
+ {t('bulletpoint')}
+
+
+ {t('bridgeTerms')}
+
+
+
+
+ ) : null}
+ >
+ );
};
diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.test.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.test.tsx
index 42c5968bb5b0..ac5e384413d8 100644
--- a/ui/pages/bridge/quotes/bridge-quotes-modal.test.tsx
+++ b/ui/pages/bridge/quotes/bridge-quotes-modal.test.tsx
@@ -6,6 +6,8 @@ import mockBridgeQuotesErc20Erc20 from '../../../../test/data/bridge/mock-quotes
import { createBridgeMockStore } from '../../../../test/jest/mock-store';
import { renderWithProvider } from '../../../../test/lib/render-helpers';
import configureStore from '../../../store/store';
+import { CHAIN_IDS } from '../../../../shared/constants/network';
+import { mockNetworkState } from '../../../../test/stub/networks';
import { BridgeQuotesModal } from './bridge-quotes-modal';
describe('BridgeQuotesModal', () => {
@@ -16,6 +18,27 @@ describe('BridgeQuotesModal', () => {
getQuotesLastFetched: Date.now(),
quotesLoadingStatus: RequestStatus.FETCHED,
},
+ bridgeSliceOverrides: {
+ fromTokenExchangeRate: 1,
+ toTokenExchangeRate: 0.99,
+ },
+ metamaskStateOverrides: {
+ currencyRates: {
+ ETH: {
+ conversionRate: 1,
+ },
+ POL: {
+ conversionRate: 1,
+ usdConversionRate: 1,
+ },
+ },
+ ...mockNetworkState(
+ { chainId: CHAIN_IDS.MAINNET },
+ { chainId: CHAIN_IDS.LINEA_MAINNET },
+ { chainId: CHAIN_IDS.POLYGON },
+ { chainId: CHAIN_IDS.OPTIMISM },
+ ),
+ },
});
const { baseElement } = renderWithProvider(
diff --git a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx
index d251b8ab72f5..e9cb65f612fe 100644
--- a/ui/pages/bridge/quotes/bridge-quotes-modal.tsx
+++ b/ui/pages/bridge/quotes/bridge-quotes-modal.tsx
@@ -24,7 +24,7 @@ import {
formatTokenAmount,
} from '../utils/quote';
import { useI18nContext } from '../../../hooks/useI18nContext';
-import { getCurrentCurrency } from '../../../selectors';
+import { getCurrentCurrency, getLocale } from '../../../selectors';
import { setSelectedQuote, setSortOrder } from '../../../ducks/bridge/actions';
import { SortOrder } from '../types';
import {
@@ -52,6 +52,7 @@ export const BridgeQuotesModal = ({
const sortOrder = useSelector(getBridgeSortOrder);
const currency = useSelector(getCurrentCurrency);
const nativeCurrency = useSelector(getNativeCurrency);
+ const locale = useSelector(getLocale);
const trackCrossChainSwapsEvent = useCrossChainSwapsEventTracker();
const { quoteRequestProperties } = useRequestProperties();
@@ -117,15 +118,19 @@ export const BridgeQuotesModal = ({
color={
sortOrder === sortOrderOption
? TextColor.primaryDefault
- : TextColor.textAlternative
+ : TextColor.textAlternativeSoft
}
>
{label}
@@ -141,6 +146,7 @@ export const BridgeQuotesModal = ({
estimatedProcessingTimeInSeconds,
toTokenAmount,
cost,
+ sentAmount,
quote: { destAsset, bridges, requestId },
} = quote;
const isQuoteActive = requestId === activeQuote?.quote.requestId;
@@ -176,7 +182,7 @@ export const BridgeQuotesModal = ({
paddingInline={4}
paddingTop={3}
paddingBottom={3}
- style={{ position: 'relative', height: 78 }}
+ style={{ position: 'relative' }}
>
{isQuoteActive && (
{[
- totalNetworkFee?.valueInCurrency
- ? t('quotedNetworkFee', [
+ totalNetworkFee?.valueInCurrency &&
+ sentAmount?.valueInCurrency
+ ? t('quotedTotalCost', [
formatCurrencyAmount(
- totalNetworkFee.valueInCurrency,
+ totalNetworkFee.valueInCurrency.plus(
+ sentAmount.valueInCurrency,
+ ),
currency,
0,
),
])
- : t('quotedNetworkFee', [
+ : t('quotedTotalCost', [
formatTokenAmount(
+ locale,
totalNetworkFee.amount,
nativeCurrency,
),
]),
- t(
- sortOrder === SortOrder.ETA_ASC
- ? 'quotedReceivingAmount'
- : 'quotedReceiveAmount',
- [
- formatCurrencyAmount(
- toTokenAmount.valueInCurrency,
- currency,
- 0,
- ) ??
- formatTokenAmount(
- toTokenAmount.amount,
- destAsset.symbol,
- 0,
- ),
- ],
- ),
- ]
- [sortOrder === SortOrder.ETA_ASC ? 'reverse' : 'slice']()
- .map((content) => (
-
- {content}
-
- ))}
+ t('quotedReceiveAmount', [
+ formatCurrencyAmount(
+ toTokenAmount.valueInCurrency,
+ currency,
+ 0,
+ ) ??
+ formatTokenAmount(
+ locale,
+ toTokenAmount.amount,
+ destAsset.symbol,
+ ),
+ ]),
+ ].map((content) => (
+
+ {content}
+
+ ))}
diff --git a/ui/pages/bridge/quotes/index.scss b/ui/pages/bridge/quotes/index.scss
index 0d9eafa9ad69..ffd58f48d8fe 100644
--- a/ui/pages/bridge/quotes/index.scss
+++ b/ui/pages/bridge/quotes/index.scss
@@ -1,68 +1,5 @@
@use "design-system";
-.quote-card {
- flex-direction: column;
- display: flex;
- text-align: center;
-
- p {
- font-size: 12px;
- }
-
-
- &__content {
- padding: 16px;
- gap: 4px;
- }
-
- &__timer {
- height: 32px;
-
- p {
- color: var(--color-text-alternative);
- }
- }
-
- .bridge-box > &__footer {
- gap: 0;
-
- p {
- color: var(--color-text-alternative);
- }
- }
-
- &__info-row {
- display: flex;
- flex-direction: row;
- justify-content: space-between;
-
- &__label {
- display: inline-flex;
- gap: 4px;
-
- p {
- font-weight: var(--font-weight-medium);
- font-size: 14px;
- white-space: nowrap;
- }
-
- &__tooltip {
- align-items: center;
- height: 100%;
- }
- }
-
- &__description {
- display: inline-flex;
- gap: 4px;
-
- &__secondary {
- color: var(--color-text-alternative);
- }
- }
- }
-}
-
.quotes-modal {
.mm-modal-content__dialog {
display: flex;
diff --git a/ui/pages/bridge/quotes/quote-info-row.tsx b/ui/pages/bridge/quotes/quote-info-row.tsx
deleted file mode 100644
index d1cccc62ed8e..000000000000
--- a/ui/pages/bridge/quotes/quote-info-row.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import {
- Box,
- Icon,
- IconName,
- IconSize,
- Text,
-} from '../../../components/component-library';
-import Tooltip from '../../../components/ui/tooltip';
-import { IconColor } from '../../../helpers/constants/design-system';
-
-export const QuoteInfoRow = ({
- label,
- tooltipText,
- description,
- secondaryDescription,
-}: {
- label: string;
- tooltipText?: string;
- description: string;
- secondaryDescription?: string;
-}) => {
- return (
-
-
- {label}
- {tooltipText && (
-
-
-
- )}
-
-
-
-
- {secondaryDescription}
-
- {description}
-
-
- );
-};
diff --git a/ui/pages/bridge/types.ts b/ui/pages/bridge/types.ts
index db6d7e8e1394..f8aabd51f8e8 100644
--- a/ui/pages/bridge/types.ts
+++ b/ui/pages/bridge/types.ts
@@ -1,5 +1,10 @@
import { BigNumber } from 'bignumber.js';
import { ChainConfiguration } from '../../../shared/types/bridge';
+import {
+ AssetWithDisplayData,
+ ERC20Asset,
+ NativeAsset,
+} from '../../components/multichain/asset-picker-amount/asset-picker-modal/types';
export type L1GasFees = {
l1GasFeesInHexWei?: string; // l1 fees for approval and trade in hex wei, appended by controller
@@ -23,6 +28,13 @@ export enum SortOrder {
ETA_ASC = 'time_descending',
}
+export type BridgeToken =
+ | (AssetWithDisplayData & {
+ aggregators?: string[];
+ address: string;
+ })
+ | null;
+
// Types copied from Metabridge API
export enum BridgeFlag {
EXTENSION_CONFIG = 'extension-config',
diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts
index 05acc09db520..70d382a73f23 100644
--- a/ui/pages/bridge/utils/quote.ts
+++ b/ui/pages/bridge/utils/quote.ts
@@ -10,8 +10,10 @@ import { formatCurrency } from '../../../helpers/utils/confirm-tx.util';
import { Numeric } from '../../../../shared/modules/Numeric';
import { EtherDenomination } from '../../../../shared/constants/common';
import { DEFAULT_PRECISION } from '../../../hooks/useCurrencyDisplay';
+import { formatAmount } from '../../confirmations/components/simulation-details/formatAmount';
-export const isNativeAddress = (address?: string) => address === zeroAddress();
+export const isNativeAddress = (address?: string | null) =>
+ address === zeroAddress() || address === '' || !address;
export const isValidQuoteRequest = (
partialRequest: Partial,
@@ -168,14 +170,24 @@ export const calcCost = (
: null,
});
-export const formatEtaInMinutes = (estimatedProcessingTimeInSeconds: number) =>
- (estimatedProcessingTimeInSeconds / 60).toFixed();
+export const formatEtaInMinutes = (
+ estimatedProcessingTimeInSeconds: number,
+) => {
+ if (estimatedProcessingTimeInSeconds < 60) {
+ return `< 1`;
+ }
+ return (estimatedProcessingTimeInSeconds / 60).toFixed();
+};
export const formatTokenAmount = (
+ locale: string,
amount: BigNumber,
- symbol: string,
- precision: number = 2,
-) => `${amount.toFixed(precision)} ${symbol}`;
+ symbol: string = '',
+) => {
+ const stringifiedAmount = formatAmount(locale, amount);
+
+ return [stringifiedAmount, symbol].join(' ').trim();
+};
export const formatCurrencyAmount = (
amount: BigNumber | null,
@@ -187,7 +199,7 @@ export const formatCurrencyAmount = (
}
if (precision === 0) {
if (amount.lt(0.01)) {
- return `<${formatCurrency('0', currency, precision)}`;
+ return '<$0.01';
}
if (amount.lt(1)) {
return formatCurrency(amount.toString(), currency, 2);
diff --git a/ui/pages/bridge/utils/validators.ts b/ui/pages/bridge/utils/validators.ts
index a07eae493c79..08fc3519ef52 100644
--- a/ui/pages/bridge/utils/validators.ts
+++ b/ui/pages/bridge/utils/validators.ts
@@ -16,8 +16,9 @@ export const validateResponse = (
validators: Validator[],
data: unknown,
urlUsed: string,
+ logError = true,
): data is ExpectedResponse => {
- return validateData(validators, data, urlUsed);
+ return validateData(validators, data, urlUsed, logError);
};
export const isValidNumber = (v: unknown): v is number => typeof v === 'number';
@@ -53,6 +54,15 @@ export const FEATURE_FLAG_VALIDATORS = [
},
];
+export const TOKEN_AGGREGATOR_VALIDATORS = [
+ {
+ property: 'aggregators',
+ type: 'object',
+ validator: (v: unknown): v is number[] =>
+ isValidObject(v) && Object.values(v).every(isValidString),
+ },
+];
+
export const TOKEN_VALIDATORS = [
{ property: 'decimals', type: 'number' },
{ property: 'address', type: 'string', validator: isValidHexAddress },
diff --git a/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js b/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js
index a8fddf9943d7..b5e8a04dbc5a 100644
--- a/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js
+++ b/ui/pages/swaps/mascot-background-animation/mascot-background-animation.js
@@ -1,10 +1,11 @@
/* eslint-disable @metamask/design-tokens/color-no-hex*/
import EventEmitter from 'events';
import React, { useRef } from 'react';
+import PropTypes from 'prop-types';
import Mascot from '../../../components/ui/mascot';
-export default function MascotBackgroundAnimation() {
+export default function MascotBackgroundAnimation({ height, width }) {
const animationEventEmitter = useRef(new EventEmitter());
return (
@@ -220,11 +221,16 @@ export default function MascotBackgroundAnimation() {
>
);
}
+
+MascotBackgroundAnimation.propTypes = {
+ height: PropTypes.string,
+ width: PropTypes.string,
+};