diff --git a/wormhole-connect/src/components/Button.tsx b/wormhole-connect/src/components/Button.tsx index fe30bef6f..81f8b6f08 100644 --- a/wormhole-connect/src/components/Button.tsx +++ b/wormhole-connect/src/components/Button.tsx @@ -17,7 +17,7 @@ const useStyles = makeStyles()((theme: any) => ({ }, disabled: { cursor: 'not-allowed', - clickEvents: 'none', + pointerEvents: 'none', backgroundColor: theme.palette.button.disabled + ' !important', color: theme.palette.button.disabledText + ' !important', }, diff --git a/wormhole-connect/src/views/v2/Bridge/ReviewTransaction/index.tsx b/wormhole-connect/src/hooks/useConfirmTransaction.ts similarity index 56% rename from wormhole-connect/src/views/v2/Bridge/ReviewTransaction/index.tsx rename to wormhole-connect/src/hooks/useConfirmTransaction.ts index 1a1158567..ada97388e 100644 --- a/wormhole-connect/src/views/v2/Bridge/ReviewTransaction/index.tsx +++ b/wormhole-connect/src/hooks/useConfirmTransaction.ts @@ -1,21 +1,11 @@ -import React, { useContext, useMemo, useState } from 'react'; +import { useContext, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { makeStyles } from 'tss-react/mui'; -import { useMediaQuery, useTheme } from '@mui/material'; -import CircularProgress from '@mui/material/CircularProgress'; -import Collapse from '@mui/material/Collapse'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import ChevronLeft from '@mui/icons-material/ChevronLeft'; -import IconButton from '@mui/material/IconButton'; -import { getTransferDetails } from 'telemetry'; import { Context } from 'sdklegacy'; -import Button from 'components/v2/Button'; import config from 'config'; -import { addTxToLocalStorage } from 'utils/inProgressTxCache'; import { RouteContext } from 'contexts/RouteContext'; -import { useGasSlider } from 'hooks/useGasSlider'; +import { useUSDamountGetter } from 'hooks/useUSDamountGetter'; +import { useGetTokens } from 'hooks/useGetTokens'; import { setTxDetails, setSendTx, @@ -24,57 +14,41 @@ import { } from 'store/redeem'; import { setRoute as setAppRoute } from 'store/router'; import { setAmount, setIsTransactionInProgress } from 'store/transferInput'; -import { getWrappedToken } from 'utils'; +import { getTransferDetails } from 'telemetry'; +import { ERR_USER_REJECTED } from 'telemetry/types'; +import { toDecimals } from 'utils/balance'; import { interpretTransferError } from 'utils/errors'; +import { addTxToLocalStorage } from 'utils/inProgressTxCache'; import { validate, isTransferValid } from 'utils/transferValidation'; import { registerWalletSigner, switchChain, TransferWallet, } from 'utils/wallet'; -import GasSlider from 'views/v2/Bridge/ReviewTransaction/GasSlider'; -import SingleRoute from 'views/v2/Bridge/Routes/SingleRoute'; import type { RootState } from 'store'; -import { RelayerFee } from 'store/relay'; - -import { amount as sdkAmount } from '@wormhole-foundation/sdk'; -import { toDecimals } from 'utils/balance'; -import { useUSDamountGetter } from 'hooks/useUSDamountGetter'; -import SendError from './SendError'; -import { ERR_USER_REJECTED } from 'telemetry/types'; -import { useGetTokens } from 'hooks/useGetTokens'; - -const useStyles = makeStyles()((theme) => ({ - container: { - gap: '16px', - width: '100%', - maxWidth: '420px', - }, - confirmTransaction: { - padding: '8px 16px', - borderRadius: '8px', - margin: 'auto', - maxWidth: '420px', - width: '100%', - }, -})); +import type { RelayerFee } from 'store/relay'; +import type { QuoteResult } from 'routes/operator'; type Props = { - onClose: () => void; - quotes: any; - isFetchingQuotes: boolean; + quotes: Record; }; -const ReviewTransaction = (props: Props) => { - const { classes } = useStyles(); - const dispatch = useDispatch(); - const theme = useTheme(); +type ReturnProps = { + error: string | undefined; + // errorInternal can be a result of custom validation, hence of any type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + errorInternal: any | undefined; + onConfirm: () => void; +}; - const mobile = useMediaQuery(theme.breakpoints.down('sm')); +const useConfirmTransaction = (props: Props): ReturnProps => { + const dispatch = useDispatch(); - const [sendError, setSendError] = useState(undefined); - const [sendErrorInternal, setSendErrorInternal] = useState( + const [error, setError] = useState(undefined); + // errorInternal can be a result of custom validation, hence of any type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [errorInternal, setErrorInternal] = useState( undefined, ); @@ -86,39 +60,32 @@ const ReviewTransaction = (props: Props) => { amount, fromChain: sourceChain, toChain: destChain, - isTransactionInProgress, route, validations, } = transferInput; + const { sourceToken, destToken } = useGetTokens(); + const wallet = useSelector((state: RootState) => state.wallet); const { sending: sendingWallet, receiving: receivingWallet } = wallet; const relay = useSelector((state: RootState) => state.relay); const { toNativeToken } = relay; - const getUSDAmount = useUSDamountGetter(); - - const { sourceToken, destToken } = useGetTokens(); - - const { disabled: isGasSliderDisabled, showGasSlider } = useGasSlider({ - destChain, - destToken: destToken!.key, - route, - valid: true, - isTransactionInProgress, - }); - const quoteResult = props.quotes[route ?? '']; const quote = quoteResult?.success ? quoteResult : undefined; - const receiveNativeAmount = quote?.destinationNativeGas; - const send = async () => { - setSendError(undefined); + const getUSDAmount = useUSDamountGetter(); + + const onConfirm = async () => { + // Clear previous errors + if (error) { + setError(undefined); + } if (config.ui.previewMode) { - setSendError('Connect is in preview mode'); + setError('Connect is in preview mode'); return; } @@ -135,6 +102,8 @@ const ReviewTransaction = (props: Props) => { return; } + // Validate all inputs + // The results of this check will be written back to Redux store (see transferInput.validations). await validate({ transferInput, relay, wallet }, dispatch, () => false); const valid = isTransferValid(validations); @@ -162,12 +131,12 @@ const ReviewTransaction = (props: Props) => { toWalletAddress: receivingWallet.address, }); if (!isValid) { - setSendError(error ?? 'Transfer validation failed'); + setError(error ?? 'Transfer validation failed'); return; } - } catch (e) { - setSendError('Error validating transfer'); - setSendErrorInternal(e); + } catch (e: unknown) { + setError('Error validating transfer'); + setErrorInternal(e); console.error(e); return; } @@ -176,7 +145,7 @@ const ReviewTransaction = (props: Props) => { dispatch(setIsTransactionInProgress(true)); try { - const fromConfig = config.chains[sourceChain!]; + const fromConfig = config.chains[sourceChain]; if (fromConfig?.context === Context.ETH) { const chainId = fromConfig.chainId; @@ -242,10 +211,11 @@ const ReviewTransaction = (props: Props) => { recipient: receivingWallet.address, toChain: receipt.to, fromChain: receipt.from, - tokenAddress: getWrappedToken(sourceToken).tokenId!.address.toString(), + receivedToken: destToken.tuple, token: sourceToken.tuple, + tokenAddress: sourceToken.tuple[1], + tokenKey: sourceToken.key, tokenDecimals: sourceToken.decimals, - receivedToken: destToken.tuple, // TODO: possibly wrong (e..g if portico swap fails) relayerFee, receiveAmount: quote.destinationToken.amount, receiveNativeAmount, @@ -280,8 +250,8 @@ const ReviewTransaction = (props: Props) => { dispatch(setSendTx(txId)); dispatch(setRedeemRoute(route)); dispatch(setAppRoute('redeem')); - setSendError(undefined); - } catch (e: any) { + setError(undefined); + } catch (e: unknown) { const [uiError, transferError] = interpretTransferError( e, transferDetails, @@ -294,8 +264,8 @@ const ReviewTransaction = (props: Props) => { console.error('Wormhole Connect: error completing transfer', e); // Show error in UI - setSendError(uiError); - setSendErrorInternal(e); + setError(uiError); + setErrorInternal(e); // Trigger transfer error event to integrator config.triggerEvent({ @@ -309,108 +279,11 @@ const ReviewTransaction = (props: Props) => { } }; - const walletsConnected = useMemo( - () => !!sendingWallet.address && !!receivingWallet.address, - [sendingWallet.address, receivingWallet.address], - ); - - // Review transaction button is shown only when everything is ready - const confirmTransactionButton = useMemo(() => { - if ( - !sourceChain || - !sourceToken || - !destChain || - !destToken || - !route || - !amount - ) { - return null; - } - - return ( - - ); - }, [ - props.isFetchingQuotes, - isTransactionInProgress, - sourceChain, - sourceToken, - destChain, - destToken, - route, - amount, - send, - ]); - - if (!route || !walletsConnected) { - return <>; - } - - return ( - -
- props.onClose?.()} - > - - -
- - {showGasSlider && ( - - - - )} - - {confirmTransactionButton} -
- ); + return { + onConfirm, + error, + errorInternal, + }; }; -export default ReviewTransaction; +export default useConfirmTransaction; diff --git a/wormhole-connect/src/hooks/useGasSlider.ts b/wormhole-connect/src/hooks/useGasSlider.ts index 120a72bb3..f7bfd15a9 100644 --- a/wormhole-connect/src/hooks/useGasSlider.ts +++ b/wormhole-connect/src/hooks/useGasSlider.ts @@ -5,9 +5,8 @@ import { Chain } from '@wormhole-foundation/sdk'; type Props = { destChain: Chain | undefined; - destToken: string; + destToken: string | undefined; route?: string; - valid: boolean; isTransactionInProgress: boolean; }; @@ -17,9 +16,9 @@ export const useGasSlider = ( disabled: boolean; showGasSlider: boolean | undefined; } => { - const { destChain, destToken, route, isTransactionInProgress, valid } = props; + const { destChain, destToken, route, isTransactionInProgress } = props; - const disabled = !valid || isTransactionInProgress; + const disabled = isTransactionInProgress; const toChainConfig = destChain ? config.chains[destChain] : undefined; const gasTokenConfig = toChainConfig ? config.tokens.getGasToken(toChainConfig.sdkName) diff --git a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx index b87ebf15d..0bef6cd5b 100644 --- a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx @@ -161,6 +161,10 @@ const AmountInput = (props: Props) => { amount ? sdkAmount.display(amount) : '', ); + const { fromChain: sourceChain, isTransactionInProgress } = useSelector( + (state: RootState) => state.transferInput, + ); + const { sourceToken } = useGetTokens(); const { getTokenPrice } = useTokens(); @@ -177,8 +181,8 @@ const AmountInput = (props: Props) => { }, [amount]); const isInputDisabled = useMemo( - () => !props.sourceChain || !sourceToken, - [props.sourceChain, sourceToken], + () => isTransactionInProgress || !sourceChain || !sourceToken, + [isTransactionInProgress, sourceChain, sourceToken], ); const balance = useMemo(() => { @@ -211,16 +215,20 @@ const AmountInput = (props: Props) => { ); }, [ - classes.balance, isInputDisabled, + sendingWallet.address, + classes.balance, props.isFetchingTokenBalance, props.tokenBalance, - sendingWallet.address, ]); - const handleChange = useCallback((newValue: string): void => { - setAmountInput(newValue); - }, []); + const handleChange = useCallback( + (newValue: string): void => { + dispatch(setAmount(newValue)); + setAmountInput(newValue); + }, + [dispatch], + ); const tokenPriceAdornment = useMemo(() => { const price = calculateUSDPrice( diff --git a/wormhole-connect/src/views/v2/Bridge/AssetPicker/SearchableList/index.tsx b/wormhole-connect/src/views/v2/Bridge/AssetPicker/SearchableList/index.tsx index d30fea9b7..2797a4eed 100644 --- a/wormhole-connect/src/views/v2/Bridge/AssetPicker/SearchableList/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/AssetPicker/SearchableList/index.tsx @@ -37,9 +37,11 @@ function SearchableList(props: SearchableListProps): ReactNode { const scrollbarClass = useCustomScrollbar(); const [query, setQuery] = useState(''); + const { items, filterFn } = props; + const filteredList = useMemo(() => { - return props.items.filter((item) => props.filterFn(item, query)); - }, [props.items, props.filterFn, query]); + return items.filter((item) => filterFn(item, query)); + }, [items, filterFn, query]); return ( diff --git a/wormhole-connect/src/views/v2/Bridge/AssetPicker/index.tsx b/wormhole-connect/src/views/v2/Bridge/AssetPicker/index.tsx index 21068ad97..8322fe910 100644 --- a/wormhole-connect/src/views/v2/Bridge/AssetPicker/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/AssetPicker/index.tsx @@ -3,7 +3,11 @@ import { makeStyles } from 'tss-react/mui'; import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; import Popover from '@mui/material/Popover'; -import { usePopupState, bindPopover } from 'material-ui-popup-state/hooks'; +import { + usePopupState, + bindPopover, + bindTrigger, +} from 'material-ui-popup-state/hooks'; import Typography from '@mui/material/Typography'; import DownIcon from '@mui/icons-material/ExpandMore'; @@ -20,6 +24,7 @@ import { Chain } from '@wormhole-foundation/sdk'; import AssetBadge from 'components/AssetBadge'; import { Token } from 'config/tokens'; import { Backdrop } from '@mui/material'; +import { joinClass } from 'utils/style'; const useStyles = makeStyles()((theme: any) => ({ inputArea: { @@ -50,9 +55,9 @@ const useStyles = makeStyles()((theme: any) => ({ justifyContent: 'space-between', }, disabled: { - opacity: '0.4', - cursor: 'not-allowed', - clickEvent: 'none', + opacity: '0.6', + cursor: 'default', + pointerEvents: 'none', }, popover: { marginLeft: '-1px', @@ -81,6 +86,7 @@ type Props = { setChain: (value: Chain) => void; wallet: WalletData; isSource: boolean; + isTransactionInProgress: boolean; }; const AssetPicker = (props: Props) => { @@ -161,15 +167,23 @@ const AssetPicker = (props: Props) => { ); }, [chainConfig, props.token]); + const triggerProps = props.isTransactionInProgress + ? {} + : bindTrigger(popupState); + return ( <> ({ - card: { + content: { width: '100%', cursor: 'pointer', maxWidth: '420px', overflow: 'visible', - padding: '0 4px', + padding: '16px 20px', }, container: { display: 'flex', @@ -38,6 +36,7 @@ const useStyles = makeStyles()(() => ({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', + width: '100%', }, })); @@ -50,8 +49,11 @@ const StyledSlider = styled(Slider, { shouldForwardProp: (prop) => !['baseColor', 'railColor'].includes(prop.toString()), })(({ baseColor, railColor, theme }) => ({ + alignSelf: 'start', color: baseColor, height: 8, + left: '10px', + width: 'calc(100% - 20px)', '& .MuiSlider-rail': { height: '8px', backgroundColor: railColor, @@ -69,7 +71,7 @@ const StyledSlider = styled(Slider, { const StyledSwitch = styled(Switch)(({ theme }) => ({ padding: '9px 12px', - right: `-12px`, // reposition towards right to negate switch padding + right: `-9px`, // reposition towards right to negate switch padding '& .MuiSwitch-switchBase.Mui-checked': { color: theme.palette.primary.main, }, @@ -94,12 +96,12 @@ const GasSlider = (props: { (state: RootState) => state.transferInput, ); - const { getTokenPrice, isFetchingTokenPrices } = useTokens(); + const { getTokenPrice, lastTokenPriceUpdate } = useTokens(); const destChainConfig = config.chains[destChain!]; const nativeGasToken = config.tokens.getGasToken(destChain!); - const [isGasSliderOpen, setIsGasSliderOpen] = useState(!props.disabled); + const [isGasSliderOpen, setIsGasSliderOpen] = useState(false); const [percentage, setPercentage] = useState(0); const [debouncedPercentage] = useDebounce(percentage, 500); @@ -128,9 +130,11 @@ const GasSlider = (props: { {`${tokenAmount} ${nativeGasToken.symbol} ${tokenPrice}`} ); + // We want to recompute the price after we update conversion rates (lastTokenPriceUpdate). + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ nativeGasToken, - isFetchingTokenPrices, + lastTokenPriceUpdate, props.destinationGasDrop, destChain, ]); @@ -141,60 +145,56 @@ const GasSlider = (props: { } return ( - - - - {`Need more gas on ${destChain}?`} - { - const { checked } = e.target; - - setIsGasSliderOpen(checked); - - if (!checked) { - setPercentage(0); - dispatch(setToNativeToken(0)); - } - }} - /> - - -
- - {`Use the slider to buy extra ${nativeGasToken.symbol} for future transactions.`} - -
- `${percentage}%`} - valueLabelDisplay="auto" - onChange={(e: any) => setPercentage(e.target.value)} - /> -
- - Additional gas - - - {nativeGasPrice} - -
+
+ + {`Need more gas on ${destChain}?`} + { + const { checked } = e.target; + + setIsGasSliderOpen(checked); + + if (!checked) { + setPercentage(0); + dispatch(setToNativeToken(0)); + } + }} + /> + + +
+ + {`Use the slider to buy extra ${nativeGasToken.symbol} for future transactions.`} + +
+ `${percentage}%`} + valueLabelDisplay="auto" + onChange={(e: any) => setPercentage(e.target.value)} + /> +
+ + Additional gas + + + {nativeGasPrice} +
- - - +
+
+
); }; diff --git a/wormhole-connect/src/views/v2/Bridge/ReviewTransaction/SendError.tsx b/wormhole-connect/src/views/v2/Bridge/ReviewTransaction/SendError.tsx deleted file mode 100644 index e8fa87036..000000000 --- a/wormhole-connect/src/views/v2/Bridge/ReviewTransaction/SendError.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useState } from 'react'; -import { makeStyles } from 'tss-react/mui'; -import config from 'config'; -import AlertBannerV2 from 'components/v2/AlertBanner'; -import { copyTextToClipboard } from 'utils'; -import { Box, Typography } from '@mui/material'; -import CopyIcon from '@mui/icons-material/ContentCopy'; -import DoneIcon from '@mui/icons-material/Done'; - -type Props = { - humanError?: string; - internalError?: any; -}; - -const useStyles = makeStyles()((theme: any) => ({ - copyIcon: { - fontSize: '14px', - }, - doneIcon: { - fontSize: '14px', - color: theme.palette.success.main, - }, -})); - -export default ({ humanError, internalError }: Props) => { - const { classes } = useStyles(); - - const [justCopied, setJustCopied] = useState(false); - - if (humanError === undefined) { - return null; - } - - const getHelp = - internalError && internalError.message && config.ui.getHelpUrl ? ( - - Having trouble?{' '} - { - copyTextToClipboard(internalError.message); - setJustCopied(true); - setTimeout(() => setJustCopied(false), 3000); - }} - > - Copy the error logs{' '} - {justCopied ? ( - - ) : ( - - )} - - {' and '} - - ask for help - - . - - ) : null; - - return ( - - - {getHelp} - - ); -}; diff --git a/wormhole-connect/src/views/v2/Bridge/Routes/SingleRoute.tsx b/wormhole-connect/src/views/v2/Bridge/Routes/SingleRoute.tsx index 56e1ccd95..4f4cc27ab 100644 --- a/wormhole-connect/src/views/v2/Bridge/Routes/SingleRoute.tsx +++ b/wormhole-connect/src/views/v2/Bridge/Routes/SingleRoute.tsx @@ -5,6 +5,7 @@ import Card from '@mui/material/Card'; import CardActionArea from '@mui/material/CardActionArea'; import CardContent from '@mui/material/CardContent'; import CardHeader from '@mui/material/CardHeader'; +import Collapse from '@mui/material/Collapse'; import Divider from '@mui/material/Divider'; import Typography from '@mui/material/Typography'; import Stack from '@mui/material/Stack'; @@ -12,6 +13,7 @@ import { makeStyles } from 'tss-react/mui'; import { amount, routes } from '@wormhole-foundation/sdk'; import config from 'config'; +import { useGasSlider } from 'hooks/useGasSlider'; import ErrorIcon from 'icons/Error'; import WarningIcon from 'icons/Warning'; import TokenIcon from 'icons/TokenIcons'; @@ -22,6 +24,7 @@ import { millisToHumanString, formatDuration, } from 'utils'; +import { joinClass } from 'utils/style'; import type { RootState } from 'store'; import FastestRoute from 'icons/FastestRoute'; @@ -30,6 +33,7 @@ import { useGetTokens } from 'hooks/useGetTokens'; import { useTokens } from 'contexts/TokensContext'; import { Token } from 'config/tokens'; import { opacify } from 'utils/theme'; +import GasSlider from 'views/v2/Bridge/GasSlider'; const HIGH_FEE_THRESHOLD = 20; // dollhairs @@ -86,6 +90,11 @@ const useStyles = makeStyles()((theme: any) => ({ width: '34px', marginRight: '12px', }, + disabled: { + opacity: '0.6', + cursor: 'default', + pointerEvents: 'none', + }, })); type Props = { @@ -105,17 +114,27 @@ const SingleRoute = (props: Props) => { const theme = useTheme(); const routeConfig = config.routes.get(props.route); - const { toChain: destChain, fromChain: sourceChain } = useSelector( - (state: RootState) => state.transferInput, - ); + const { + toChain: destChain, + fromChain: sourceChain, + isTransactionInProgress, + } = useSelector((state: RootState) => state.transferInput); - const { getTokenPrice, isFetchingTokenPrices } = useTokens(); + const { getTokenPrice, lastTokenPriceUpdate } = useTokens(); - const { quote } = props; + const { quote, isSelected } = props; + const receiveNativeAmount = quote?.destinationNativeGas; const { sourceToken, destToken } = useGetTokens(); - const [feePrice, isHighFee, feeToken]: [ + const { disabled: isGasSliderDisabled, showGasSlider } = useGasSlider({ + destChain, + destToken: destToken?.key, + route: props.route, + isTransactionInProgress, + }); + + const [feePrice, isHighFee, feeTokenConfig]: [ number | undefined, boolean, Token | undefined, @@ -133,14 +152,14 @@ const SingleRoute = (props: Props) => { } return [feePrice, feePrice > HIGH_FEE_THRESHOLD, feeToken]; - }, [quote?.relayFee]); + }, [getTokenPrice, quote?.relayFee]); const relayerFee = useMemo(() => { if (!routeConfig.AUTOMATIC_DEPOSIT) { return <>You pay gas on {destChain}; } - if (!quote || !feePrice || !feeToken) { + if (!quote || !feePrice || !feeTokenConfig) { return <>; } @@ -148,7 +167,7 @@ const SingleRoute = (props: Props) => { let feeValue = `${amount.display( amount.truncate(quote!.relayFee!.amount, 6), - )} ${feeToken.display} (${feePriceFormatted})`; + )} ${feeTokenConfig.display} (${feePriceFormatted})`; // Wesley made me do it // Them PMs :-/ @@ -179,7 +198,7 @@ const SingleRoute = (props: Props) => { }, [ destChain, feePrice, - feeToken, + feeTokenConfig, props.route, quote, routeConfig.AUTOMATIC_DEPOSIT, @@ -233,12 +252,16 @@ const SingleRoute = (props: Props) => { >{`${gasTokenAmount} ${nativeGasToken.symbol}${gasTokenPriceStr}`} ); + // We want to recompute the price after we update conversion rates (lastTokenPriceUpdate). + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ destChain, + lastTokenPriceUpdate, props.destinationGasDrop, - theme.palette.text.primary, + getTokenPrice, + lastTokenPriceUpdate, theme.palette.text.secondary, - isFetchingTokenPrices, + theme.palette.text.primary, ]); const timeToDestination = useMemo( @@ -516,9 +539,13 @@ const SingleRoute = (props: Props) => { component="div" >{`${usdValue} ${providerText}`} ); + // We want to recompute the price after we update conversion rates (lastTokenPriceUpdate). + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ destChain, destToken, + getTokenPrice, + lastTokenPriceUpdate, props.error, providerText, receiveAmount, @@ -529,7 +556,7 @@ const SingleRoute = (props: Props) => { // 1- If no action handler provided, fall back to default // 2- Otherwise there is an action handler, "pointer" const cursor = useMemo(() => { - if (props.isSelected || typeof props.onSelect !== 'function') { + if (isSelected || typeof props.onSelect !== 'function') { return 'default'; } @@ -538,7 +565,7 @@ const SingleRoute = (props: Props) => { } return 'pointer'; - }, [props.error, props.isSelected, props.onSelect]); + }, [props.error, isSelected, props.onSelect]); const routeCardBadge = useMemo(() => { if (props.isFastest) { @@ -572,20 +599,22 @@ const SingleRoute = (props: Props) => { return (
{ {errorMessage} {warningMessages} + {showGasSlider && ( + <> + + + + + + )}
diff --git a/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx b/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx index ce4667c5b..011c3e598 100644 --- a/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx @@ -49,7 +49,7 @@ const Routes = ({ ...props }: Props) => { const selectedRoute = routes.find((route) => route === props.selectedRoute); return selectedRoute ? [selectedRoute] : routes.slice(0, 1); - }, [showAll, routes]); + }, [showAll, routes, props.selectedRoute]); const fastestRoute = useMemo(() => { return routes.reduce( diff --git a/wormhole-connect/src/views/v2/Bridge/SwapInputs/index.tsx b/wormhole-connect/src/views/v2/Bridge/SwapInputs/index.tsx index 0bdcbb4e5..b115439b1 100644 --- a/wormhole-connect/src/views/v2/Bridge/SwapInputs/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/SwapInputs/index.tsx @@ -25,15 +25,12 @@ function SwapInputs() { const dispatch = useDispatch(); const [rotateAnimation, setRotateAnimation] = useState(''); - const { - isTransactionInProgress, - fromChain, - toChain, - destToken, - token: sourceToken, - } = useSelector((state: RootState) => state.transferInput); + const { isTransactionInProgress, fromChain, toChain } = useSelector( + (state: RootState) => state.transferInput, + ); const canSwap = + !isTransactionInProgress && fromChain && !config.chains[fromChain]?.disabledAsDestination && toChain && @@ -49,15 +46,7 @@ function SwapInputs() { dispatch(swapInputs()); dispatch(swapWallets()); dispatch(setAmount('')); - }, [ - fromChain, - toChain, - sourceToken, - destToken, - canSwap, - isTransactionInProgress, - dispatch, - ]); + }, [canSwap, isTransactionInProgress, dispatch]); const { classes } = useStyles(); diff --git a/wormhole-connect/src/views/v2/Bridge/WalletConnector/Controller.tsx b/wormhole-connect/src/views/v2/Bridge/WalletConnector/Controller.tsx index 7fb1ac905..85d1358c2 100644 --- a/wormhole-connect/src/views/v2/Bridge/WalletConnector/Controller.tsx +++ b/wormhole-connect/src/views/v2/Bridge/WalletConnector/Controller.tsx @@ -17,6 +17,7 @@ import { RootState } from 'store'; import { disconnectWallet as disconnectFromStore } from 'store/wallet'; import { TransferWallet } from 'utils/wallet'; import { copyTextToClipboard, displayWalletAddress } from 'utils'; +import { joinClass } from 'utils/style'; import WalletIcons from 'icons/WalletIcons'; import config from 'config'; @@ -38,6 +39,11 @@ const useStyles = makeStyles()((theme: any) => ({ color: theme.palette.textSecondary, marginLeft: '8px', }, + disabled: { + opacity: '0.6', + cursor: 'default', + pointerEvents: 'none', + }, dropdown: { backgroundColor: theme.palette.popover.background, display: 'flex', @@ -67,6 +73,10 @@ const ConnectedWallet = (props: Props) => { const { classes } = useStyles(); + const { isTransactionInProgress } = useSelector( + (state: RootState) => state.transferInput, + ); + const wallet = useSelector((state: RootState) => state.wallet[props.type]); const [isOpen, setIsOpen] = useState(false); @@ -80,18 +90,18 @@ const ConnectedWallet = (props: Props) => { const connectWallet = useCallback(() => { popupState?.close(); setIsOpen(true); - }, []); + }, [popupState]); const copyAddress = useCallback(() => { copyTextToClipboard(wallet.address); popupState?.close(); setIsCopied(true); - }, [wallet.address]); + }, [popupState, wallet.address]); const disconnectWallet = useCallback(() => { dispatch(disconnectFromStore(props.type)); popupState?.close(); - }, [props.type]); + }, [dispatch, popupState, props.type]); useEffect(() => { if (isCopied) { @@ -101,13 +111,21 @@ const ConnectedWallet = (props: Props) => { } }, [isCopied]); + const popupTrigger = isTransactionInProgress ? {} : bindTrigger(popupState); + if (!wallet?.address) { return <>; } return ( <> -
+
({ display: 'flex', alignItems: 'center', }, - ctaContainer: { - marginTop: '8px', + doneIcon: { + fontSize: '14px', + color: theme.palette.success.main, + }, + confirmTransaction: { + padding: '8px 16px', + borderRadius: '8px', + height: '48px', + margin: 'auto', + maxWidth: '420px', width: '100%', }, - header: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', + copyIcon: { + fontSize: '14px', + }, + ctaContainer: { + marginTop: '8px', width: '100%', }, spacer: { @@ -102,6 +114,7 @@ const Bridge = () => { const dispatch = useDispatch(); const { lastTokenCacheUpdate } = useTokens(); + const [errorCopied, setErrorCopied] = useState(false); const mobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -110,9 +123,6 @@ const Bridge = () => { (state: RootState) => state.wallet, ); - const [selectedRoute, setSelectedRoute] = useState(); - const [willReviewTransaction, setWillReviewTransaction] = useState(false); - const { fromChain: sourceChain, toChain: destChain, @@ -121,6 +131,7 @@ const Bridge = () => { supportedSourceTokens, amount, validations, + isTransactionInProgress, } = useSelector((state: RootState) => state.transferInput); const { sourceToken, destToken } = useGetTokens(); @@ -140,7 +151,7 @@ const Bridge = () => { destChain, sourceToken, destToken, - route: selectedRoute, + route, }); // Compute and set destination tokens @@ -149,30 +160,38 @@ const Bridge = () => { sourceChain, destChain, sourceToken, - route: selectedRoute, + route, }); + const { + error: txError, + errorInternal: txErrorInternal, + onConfirm, + } = useConfirmTransaction({ quotes: quotesMap }); + // Set selectedRoute if the route is auto-selected // After the auto-selection, we set selectedRoute when user clicks on a route in the list useEffect(() => { if (sortedRoutesWithQuotes.length === 0) { - setSelectedRoute(''); + dispatch(setTransferRoute('')); } else { const preferredRoute = sortedRoutesWithQuotes.find( (route) => route.route === preferredRouteName, ); const autoselectedRoute = route ?? preferredRoute?.route ?? sortedRoutesWithQuotes[0].route; + const isSelectedRouteValid = - sortedRoutesWithQuotes.findIndex((r) => r.route === selectedRoute) > -1; + sortedRoutesWithQuotes.findIndex((r) => r.route === autoselectedRoute) > + -1; if (!isSelectedRouteValid) { - setSelectedRoute(''); + dispatch(setTransferRoute('')); } // If no route is autoselected or we already have a valid selected route, // we should avoid overwriting it - if (!autoselectedRoute || (selectedRoute && isSelectedRouteValid)) { + if (!autoselectedRoute || (route && isSelectedRouteValid)) { return; } @@ -180,9 +199,9 @@ const Bridge = () => { (rs) => rs.route === autoselectedRoute, ); - if (routeData) setSelectedRoute(routeData.route); + if (routeData) dispatch(setTransferRoute(routeData.route)); } - }, [route, sortedRoutesWithQuotes]); + }, [preferredRouteName, route, sortedRoutesWithQuotes]); // Pre-fetch available routes useFetchSupportedRoutes(); @@ -229,7 +248,7 @@ const Bridge = () => { // All supported chains from the given configuration and any custom override const supportedChains = useMemo( () => config.routes.allSupportedChains(), - [config.chainsArr], + [config.chains], ); const sourceTokens = useMemo(() => { @@ -249,7 +268,7 @@ const Bridge = () => { supportedChains.includes(chain.key) ); }); - }, [config.chainsArr, destChain, supportedChains]); + }, [destChain, supportedChains]); // Supported chains for the destination network const supportedDestChains = useMemo(() => { @@ -259,7 +278,7 @@ const Bridge = () => { !chain.disabledAsDestination && supportedChains.includes(chain.key), ); - }, [config.chainsArr, sourceChain, supportedChains]); + }, [sourceChain, supportedChains]); // Connect bridge header, which renders any custom overrides for the header const header = useMemo(() => { @@ -277,7 +296,7 @@ const Bridge = () => { } return ; - }, [config.ui]); + }, []); // Asset picker for the source network and token const sourceAssetPicker = useMemo(() => { @@ -303,19 +322,24 @@ const Bridge = () => { }} wallet={sendingWallet} isSource={true} + isTransactionInProgress={isTransactionInProgress} />
); }, [ + classes.assetPickerContainer, + classes.assetPickerTitle, sourceChain, supportedSourceChains, sourceToken, sourceTokens, lastTokenCacheUpdate, supportedSourceTokens, - sendingWallet, isFetchingSupportedSourceTokens, + isTransactionInProgress, + sendingWallet, + dispatch, ]); // Asset picker for the destination network and token @@ -343,21 +367,29 @@ const Bridge = () => { }} wallet={receivingWallet} isSource={false} + isTransactionInProgress={isTransactionInProgress} />
); }, [ + classes.assetPickerContainer, + classes.assetPickerTitle, destChain, supportedDestChains, destToken, + sourceToken, supportedDestTokens, - receivingWallet, isFetchingSupportedDestTokens, + isTransactionInProgress, + receivingWallet, + dispatch, ]); // Header for Bridge view, which includes the title and settings icon. const bridgeHeader = useMemo(() => { - const isTxHistoryDisabled = !sendingWallet?.address; + const isTxHistoryDisabled = + !sendingWallet?.address || isTransactionInProgress; + return (
{ size={18} /> {
); - }, [sendingWallet?.address, config.ui]); + }, [ + classes.bridgeHeader, + dispatch, + isTransactionInProgress, + sendingWallet?.address, + ]); const walletConnector = useMemo(() => { if (sendingWallet?.address && receivingWallet?.address) { @@ -413,6 +450,54 @@ const Bridge = () => { routes: sortedRoutes, }); + const transactionError = useMemo(() => { + if (!txError) { + return null; + } + + return ( + + + {txErrorInternal && txErrorInternal.message && config.ui.getHelpUrl ? ( + + Having trouble?{' '} + { + copyTextToClipboard(txErrorInternal.message); + setErrorCopied(true); + setTimeout(() => setErrorCopied(false), 3000); + }} + > + Copy the error logs{' '} + {errorCopied ? ( + + ) : ( + + )} + + {' and '} + + ask for help + + . + + ) : null} + + ); + }, [ + classes.copyIcon, + classes.doneIcon, + errorCopied, + txError, + txErrorInternal, + ]); + const hasError = !!amountValidation.error; const hasEnteredAmount = amount && sdkAmount.whole(amount) > 0; @@ -422,36 +507,70 @@ const Bridge = () => { const showRoutes = hasConnectedWallets && isWalletCompatible && hasEnteredAmount && !hasError; - const reviewTransactionDisabled = + const confirmTransactionDisabled = !sourceChain || !sourceToken || !destChain || !destToken || !hasConnectedWallets || !isWalletCompatible || - !selectedRoute || + !route || !isValid || isFetchingQuotes || !hasEnteredAmount || + isTransactionInProgress || hasError; // Review transaction button is shown only when everything is ready - const reviewTransactionButton = ( - - ); + const confirmTransactionButton = useMemo(() => { + return ( + + ); + }, [ + confirmTransactionDisabled, + classes.confirmTransaction, + isTransactionInProgress, + theme.palette.primary.contrastText, + mobile, + isFetchingQuotes, + onConfirm, + ]); - const reviewButtonTooltip = + const confirmButtonTooltip = !sourceChain || !sourceToken ? 'Please select a source asset' : !destChain || !destToken @@ -460,24 +579,16 @@ const Bridge = () => { ? 'Please enter an amount' : isFetchingQuotes ? 'Loading quotes...' - : !selectedRoute + : !route ? 'Please select a quote' : ''; - if (willReviewTransaction) { - return ( - setWillReviewTransaction(false)} - /> - ); - } - return (
{header} - {config.ui.showInProgressWidget && } + {config.ui.showInProgressWidget && ( + + )} {bridgeHeader} {sourceAssetPicker} {destAssetPicker} @@ -492,17 +603,20 @@ const Bridge = () => { {showRoutes && ( { + dispatch(setTransferRoute(r)); + }} quotes={quotesMap} isLoading={isFetchingQuotes || isFetchingBalances} hasError={hasError} /> )} + {transactionError} {hasConnectedWallets ? ( - - {reviewTransactionButton} + + {confirmTransactionButton} ) : ( walletConnector diff --git a/wormhole-connect/src/views/v2/TxHistory/Widget/Item.tsx b/wormhole-connect/src/views/v2/TxHistory/Widget/Item.tsx index 4b1d33ff8..dff6471d9 100644 --- a/wormhole-connect/src/views/v2/TxHistory/Widget/Item.tsx +++ b/wormhole-connect/src/views/v2/TxHistory/Widget/Item.tsx @@ -81,6 +81,7 @@ const useStyles = makeStyles()((theme: any) => ({ type Props = { data: TransactionLocal; + disabled: boolean; }; const WidgetItem = (props: Props) => { @@ -270,7 +271,7 @@ const WidgetItem = (props: Props) => { diff --git a/wormhole-connect/src/views/v2/TxHistory/Widget/index.tsx b/wormhole-connect/src/views/v2/TxHistory/Widget/index.tsx index b7951b74d..5cbbbc889 100644 --- a/wormhole-connect/src/views/v2/TxHistory/Widget/index.tsx +++ b/wormhole-connect/src/views/v2/TxHistory/Widget/index.tsx @@ -36,7 +36,7 @@ const useStyles = makeStyles()((theme) => ({ }, })); -const TxHistoryWidget = () => { +const TxHistoryWidget = (props: { disabled: boolean }) => { const { classes } = useStyles(); const theme = useTheme(); @@ -77,7 +77,7 @@ const TxHistoryWidget = () => {
{transactions.map((tx) => ( - + ))}
);