From 5704432968c4733a47a32d6ceea06dc0f3105cd0 Mon Sep 17 00:00:00 2001 From: Armen Nikoyan Date: Mon, 16 Sep 2024 17:32:30 +0400 Subject: [PATCH 1/9] refactor(input/routes): show only allowed routes --- ...edRoutes.ts => useFetchSupportedRoutes.ts} | 4 +- .../src/hooks/useSortedRoutesWithQuotes.ts | 118 ++++++++++++++++++ .../src/hooks/useSortedSupportedRoutes.ts | 85 ------------- .../src/views/Bridge/RouteOptions.tsx | 4 +- .../src/views/v2/Bridge/AmountInput/index.tsx | 74 +++-------- .../src/views/v2/Bridge/index.tsx | 30 ++--- 6 files changed, 146 insertions(+), 169 deletions(-) rename wormhole-connect/src/hooks/{useSupportedRoutes.ts => useFetchSupportedRoutes.ts} (96%) create mode 100644 wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts delete mode 100644 wormhole-connect/src/hooks/useSortedSupportedRoutes.ts diff --git a/wormhole-connect/src/hooks/useSupportedRoutes.ts b/wormhole-connect/src/hooks/useFetchSupportedRoutes.ts similarity index 96% rename from wormhole-connect/src/hooks/useSupportedRoutes.ts rename to wormhole-connect/src/hooks/useFetchSupportedRoutes.ts index 717aa7470..dac46aed9 100644 --- a/wormhole-connect/src/hooks/useSupportedRoutes.ts +++ b/wormhole-connect/src/hooks/useFetchSupportedRoutes.ts @@ -8,7 +8,7 @@ import type { RootState } from 'store'; import config from 'config'; import { getTokenDetails } from 'telemetry'; -const useAvailableRoutes = (): void => { +const useFetchSupportedRoutes = (): void => { const dispatch = useDispatch(); const { token, destToken, fromChain, toChain, amount } = useSelector( @@ -92,4 +92,4 @@ const useAvailableRoutes = (): void => { ]); }; -export default useAvailableRoutes; +export default useFetchSupportedRoutes; diff --git a/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts b/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts new file mode 100644 index 000000000..93ee101d6 --- /dev/null +++ b/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts @@ -0,0 +1,118 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import type { RootState } from 'store'; +import { routes, amount as sdkAmount } from '@wormhole-foundation/sdk'; +import useRoutesQuotesBulk from 'hooks/useRoutesQuotesBulk'; +import config from 'config'; +import { RouteState } from 'store/transferInput'; + +type Quote = routes.Quote< + routes.Options, + routes.ValidatedTransferParams +>; +type RouteWithQuote = { + route: RouteState; + quote: Quote; +}; + +type HookReturn = { + allSupportedRoutes: RouteState[]; + sortedRoutes: RouteState[]; + quotesMap: ReturnType['quotesMap']; + isFetchingQuotes: boolean; +}; + +export const useSortedRoutesWithQuotes = (): HookReturn => { + const { amount, routeStates, fromChain, token, toChain, destToken } = + useSelector((state: RootState) => state.transferInput); + const { toNativeToken } = useSelector((state: RootState) => state.relay); + + const supportedRoutes = useMemo( + () => (routeStates || []).filter((rs) => rs.supported), + [routeStates], + ); + + const supportedRoutesNames = useMemo( + () => supportedRoutes.map((r) => r.name), + [supportedRoutes], + ); + + const { quotesMap, isFetching } = useRoutesQuotesBulk(supportedRoutesNames, { + amount, + sourceChain: fromChain, + sourceToken: token, + destChain: toChain, + destToken, + nativeGas: toNativeToken, + }); + + const routesWithQuotes = useMemo(() => { + return supportedRoutes + .map((route) => { + const quote = quotesMap[route.name]; + if (quote?.success) { + return { + route, + quote, + }; + } else { + return undefined; + } + }) + .filter(Boolean) as RouteWithQuote[]; + // Safe to cast, as falsy values are filtered + }, [supportedRoutes, quotesMap]); + + // Only routes with quotes are sorted. + const sortedRoutes = useMemo(() => { + return [...routesWithQuotes] + .sort((routeA, routeB) => { + const routeConfigA = config.routes.get(routeA.route.name); + const routeConfigB = config.routes.get(routeB.route.name); + + // 1. Prioritize automatic routes + if (routeConfigA.AUTOMATIC_DEPOSIT && !routeConfigB.AUTOMATIC_DEPOSIT) { + return -1; + } else if ( + !routeConfigA.AUTOMATIC_DEPOSIT && + routeConfigB.AUTOMATIC_DEPOSIT + ) { + return 1; + } + + // 2. Prioritize estimated time + if (routeA.quote.eta && routeB.quote.eta) { + if (routeA.quote.eta > routeB.quote.eta) { + return 1; + } else if (routeA.quote.eta < routeB.quote.eta) { + return -1; + } + } + + // 3. Compare relay fees + if (routeA.quote.relayFee && routeB.quote.relayFee) { + const relayFeeA = sdkAmount.whole(routeA.quote.relayFee.amount); + const relayFeeB = sdkAmount.whole(routeB.quote.relayFee.amount); + if (relayFeeA > relayFeeB) { + return 1; + } else if (relayFeeA < relayFeeB) { + return -1; + } + } + + // Don't swap when routes match by all criteria or don't have quotas + return 0; + }) + .map((routeWithQuote) => routeWithQuote.route); + }, [routesWithQuotes]); + + return useMemo( + () => ({ + allSupportedRoutes: supportedRoutes, + sortedRoutes, + quotesMap, + isFetchingQuotes: isFetching, + }), + [supportedRoutes, sortedRoutes, quotesMap, isFetching], + ); +}; diff --git a/wormhole-connect/src/hooks/useSortedSupportedRoutes.ts b/wormhole-connect/src/hooks/useSortedSupportedRoutes.ts deleted file mode 100644 index 2f9c1b0bc..000000000 --- a/wormhole-connect/src/hooks/useSortedSupportedRoutes.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import type { RootState } from 'store'; -import { amount as sdkAmount } from '@wormhole-foundation/sdk'; -import useRoutesQuotesBulk from 'hooks/useRoutesQuotesBulk'; -import config from 'config'; -import { RouteState } from 'store/transferInput'; - -export const useSortedSupportedRoutes = (): RouteState[] => { - const { amount, routeStates, fromChain, token, toChain, destToken } = - useSelector((state: RootState) => state.transferInput); - const { toNativeToken } = useSelector((state: RootState) => state.relay); - - const supportedRoutes = useMemo( - () => (routeStates || []).filter((rs) => rs.supported), - [routeStates], - ); - - const supportedRoutesNames = useMemo( - () => supportedRoutes.map((r) => r.name), - [supportedRoutes], - ); - - const { quotesMap } = useRoutesQuotesBulk(supportedRoutesNames, { - amount, - sourceChain: fromChain, - sourceToken: token, - destChain: toChain, - destToken, - nativeGas: toNativeToken, - }); - - return useMemo( - () => - [...supportedRoutes].sort((routeA, routeB) => { - const quoteA = quotesMap[routeA.name]; - const quoteB = quotesMap[routeB.name]; - const routeConfigA = config.routes.get(routeA.name); - const routeConfigB = config.routes.get(routeB.name); - - // 1. Prioritize automatic routes - if (routeConfigA.AUTOMATIC_DEPOSIT && !routeConfigB.AUTOMATIC_DEPOSIT) { - return -1; - } else if ( - !routeConfigA.AUTOMATIC_DEPOSIT && - routeConfigB.AUTOMATIC_DEPOSIT - ) { - return 1; - } - - if (quoteA?.success && quoteB?.success) { - // 2. Prioritize estimated time - if (quoteA?.eta && quoteB?.eta) { - if (quoteA.eta > quoteB.eta) { - return 1; - } else if (quoteA.eta < quoteB.eta) { - return -1; - } - } - - // 3. Compare relay fees - if (quoteA?.relayFee && quoteB?.relayFee) { - const relayFeeA = sdkAmount.whole(quoteA.relayFee.amount); - const relayFeeB = sdkAmount.whole(quoteB.relayFee.amount); - if (relayFeeA > relayFeeB) { - return 1; - } else if (relayFeeA < relayFeeB) { - return -1; - } - } - } - - // 4. Prioritize routes with quotes - if (quoteA?.success && !quoteB?.success) { - return -1; - } else if (!quoteA?.success && quoteB?.success) { - return 1; - } - - // Don't swap when routes match by all criteria or don't have quotas - return 0; - }), - [supportedRoutes, quotesMap], - ); -}; diff --git a/wormhole-connect/src/views/Bridge/RouteOptions.tsx b/wormhole-connect/src/views/Bridge/RouteOptions.tsx index fc47bac5a..b32805dcf 100644 --- a/wormhole-connect/src/views/Bridge/RouteOptions.tsx +++ b/wormhole-connect/src/views/Bridge/RouteOptions.tsx @@ -17,7 +17,7 @@ import ArrowRightIcon from 'icons/ArrowRight'; import Options from 'components/Options'; import Price from 'components/Price'; import { finality, Chain } from '@wormhole-foundation/sdk'; -import useSupportedRoutes from 'hooks/useSupportedRoutes'; +import useFetchSupportedRoutes from 'hooks/useFetchSupportedRoutes'; const useStyles = makeStyles()((theme: any) => ({ link: { @@ -353,7 +353,7 @@ function RouteOptions() { (state: RootState) => state.transferInput, ); - useSupportedRoutes(); + useFetchSupportedRoutes(); const onSelect = useCallback( (value: string) => { diff --git a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx index f2c5bdc1a..f818c001f 100644 --- a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx @@ -1,15 +1,7 @@ -import React, { - useCallback, - useMemo, - useState, - ComponentProps, - memo, - useEffect, -} from 'react'; +import React, { ChangeEventHandler, ComponentProps, memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; -import { useDebounce } from 'use-debounce'; -import { usePrevious } from 'utils'; +import { useDebouncedCallback } from 'use-debounce'; import { useTheme } from '@mui/material'; import Button from '@mui/material/Button'; import Card from '@mui/material/Card'; @@ -23,7 +15,6 @@ import Typography from '@mui/material/Typography'; import AlertBannerV2 from 'components/v2/AlertBanner'; import useGetTokenBalances from 'hooks/useGetTokenBalances'; import { setAmount } from 'store/transferInput'; -import { getMaxAmt, validateAmount } from 'utils/transferValidation'; import type { TokenConfig } from 'config/types'; import type { RootState } from 'store'; @@ -39,27 +30,17 @@ const DebouncedTextField = memo( onChange: (event: string) => void; }) => { const [innerValue, setInnerValue] = useState(value); - const [deferredValue] = useDebounce(innerValue, INPUT_DEBOUNCE); - const prev = usePrevious(deferredValue); + const defferedOnChange = useDebouncedCallback(onChange, INPUT_DEBOUNCE); - const onInnerChange = useCallback( - ( - e: Parameters< - NonNullable['onChange']> - >[0], - ) => { + const onInnerChange: ChangeEventHandler = useCallback( + (e) => { if (Number(e.target.value) < 0) return; // allows "everything" but negative numbers setInnerValue(e.target.value); + defferedOnChange(e.target.value); }, [], ); - useEffect(() => { - if (prev !== deferredValue && deferredValue !== value) { - onChange(deferredValue); - } - }, [onChange, deferredValue, prev, value]); - useEffect(() => { setInnerValue(value); }, [value]); @@ -91,6 +72,7 @@ const useStyles = makeStyles()((theme) => ({ type Props = { supportedSourceTokens: Array; + error?: string; }; /** @@ -109,13 +91,8 @@ const AmountInput = (props: Props) => { fromChain: sourceChain, token: sourceToken, amount, - route, } = useSelector((state: RootState) => state.transferInput); - const prevAmount = usePrevious(amount); - - const [amountLocal, setAmountLocal] = useState(amount); - const { balances, isFetching } = useGetTokenBalances( sendingWallet?.address || '', sourceChain, @@ -127,29 +104,6 @@ const AmountInput = (props: Props) => { [balances, sourceToken], ); - const validationResult = useMemo( - () => validateAmount(amountLocal, tokenBalance, getMaxAmt(route)), - [amountLocal, tokenBalance, route], - ); - - // Debouncing validation to prevent false-positive results while user is still typing - const [debouncedValidationResult] = useDebounce(validationResult, 500); - - useEffect(() => { - // Update the redux state only when the amount is valid - // This will prevent unnecessary API calls triggered by an amount change - if (!validationResult) { - dispatch(setAmount(amountLocal)); - } - }, [amountLocal, validationResult]); - - useEffect(() => { - // Updating local state when amount has been changed from outside - if (amount !== prevAmount && amount !== amountLocal) { - setAmountLocal(amount); - } - }, [amount, prevAmount]); - const isInputDisabled = useMemo( () => !sourceChain || !sourceToken, [sourceChain, sourceToken], @@ -196,6 +150,10 @@ const AmountInput = (props: Props) => { ); }, [isInputDisabled, tokenBalance]); + const handleChange = useCallback((newValue: string): void => { + dispatch(setAmount(newValue)); + }, []); + return (
@@ -208,7 +166,7 @@ const AmountInput = (props: Props) => { disabled={isInputDisabled} inputProps={{ style: { - color: debouncedValidationResult + color: props.error ? theme.palette.error.light : theme.palette.text.primary, fontSize: 24, @@ -218,8 +176,8 @@ const AmountInput = (props: Props) => { }} placeholder="0" variant="standard" - value={amountLocal} - onChange={setAmountLocal} + value={amount} + onChange={handleChange} onWheel={(e) => { // IMPORTANT: We need to prevent the scroll behavior on number inputs. // Otherwise it'll increase/decrease the value when user scrolls on the input control. @@ -245,8 +203,8 @@ const AmountInput = (props: Props) => { diff --git a/wormhole-connect/src/views/v2/Bridge/index.tsx b/wormhole-connect/src/views/v2/Bridge/index.tsx index 67272d29d..06f735e2a 100644 --- a/wormhole-connect/src/views/v2/Bridge/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/index.tsx @@ -17,9 +17,8 @@ import PoweredByIcon from 'icons/PoweredBy'; import PageHeader from 'components/PageHeader'; import Header, { Alignment } from 'components/Header'; import FooterNavBar from 'components/FooterNavBar'; -import useSupportedRoutes from 'hooks/useSupportedRoutes'; +import useFetchSupportedRoutes from 'hooks/useFetchSupportedRoutes'; import useComputeDestinationTokens from 'hooks/useComputeDestinationTokens'; -import useRoutesQuotesBulk from 'hooks/useRoutesQuotesBulk'; import useComputeSourceTokens from 'hooks/useComputeSourceTokens'; import { setRoute as setAppRoute } from 'store/router'; import { @@ -39,7 +38,7 @@ import AmountInput from 'views/v2/Bridge/AmountInput'; import Routes from 'views/v2/Bridge/Routes'; import ReviewTransaction from 'views/v2/Bridge/ReviewTransaction'; import SwapInputs from 'views/v2/Bridge/SwapInputs'; -import { useSortedSupportedRoutes } from 'hooks/useSortedSupportedRoutes'; +import { useSortedRoutesWithQuotes } from 'hooks/useSortedRoutesWithQuotes'; import { useFetchTokenPrices } from 'hooks/useFetchTokenPrices'; import type { Chain } from '@wormhole-foundation/sdk'; @@ -114,8 +113,6 @@ const Bridge = () => { const [selectedRoute, setSelectedRoute] = useState(); const [willReviewTransaction, setWillReviewTransaction] = useState(false); - const { toNativeToken } = useSelector((state: RootState) => state.relay); - const { fromChain: sourceChain, toChain: destChain, @@ -129,7 +126,8 @@ const Bridge = () => { validations, } = useSelector((state: RootState) => state.transferInput); - const sortedSupportedRoutes = useSortedSupportedRoutes(); + const { sortedRoutes, allSupportedRoutes, quotesMap, isFetchingQuotes } = + useSortedRoutesWithQuotes(); // Compute and set source tokens const { isFetching: isFetchingSupportedSourceTokens } = @@ -150,22 +148,10 @@ const Bridge = () => { route: selectedRoute, }); - const { quotesMap, isFetching: isFetchingQuotes } = useRoutesQuotesBulk( - sortedSupportedRoutes.map((r) => r.name), - { - amount, - sourceChain, - sourceToken, - destChain, - destToken, - nativeGas: toNativeToken, - }, - ); - // 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(() => { - const validRoutes = sortedSupportedRoutes.filter((rs) => rs.supported); + const validRoutes = sortedRoutes.filter((rs) => rs.supported); const routesWithSuccessfulQuote = validRoutes.filter( (rs) => quotesMap[rs.name]?.success, @@ -185,10 +171,10 @@ const Bridge = () => { if (routeState) setSelectedRoute(routeState.name); } - }, [route, sortedSupportedRoutes, quotesMap]); + }, [route, sortedRoutes, quotesMap]); // Pre-fetch available routes - useSupportedRoutes(); + useFetchSupportedRoutes(); // Connect to any previously used wallets for the selected networks useConnectToLastUsedWallet(); @@ -428,7 +414,7 @@ const Bridge = () => { Date: Mon, 16 Sep 2024 18:42:19 +0400 Subject: [PATCH 2/9] refactor(routes): Group automatic and manual routes --- .../src/hooks/useSortedRoutesWithQuotes.ts | 2 +- .../src/views/v2/Bridge/AmountInput/index.tsx | 6 +++ .../views/v2/Bridge/Routes/SingleRoute.tsx | 6 +-- .../src/views/v2/Bridge/Routes/index.tsx | 42 ++++++++++++------- .../src/views/v2/Bridge/index.tsx | 4 +- 5 files changed, 40 insertions(+), 20 deletions(-) diff --git a/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts b/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts index 93ee101d6..3aaa85724 100644 --- a/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts +++ b/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts @@ -60,7 +60,7 @@ export const useSortedRoutesWithQuotes = (): HookReturn => { } }) .filter(Boolean) as RouteWithQuote[]; - // Safe to cast, as falsy values are filtered + // Safe to cast, as falsy values are filtered }, [supportedRoutes, quotesMap]); // Only routes with quotes are sorted. diff --git a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx index f818c001f..050db3e85 100644 --- a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx @@ -173,6 +173,12 @@ const AmountInput = (props: Props) => { height: '40px', padding: '4px', }, + onWheel: (e) => { + // IMPORTANT: We need to prevent the scroll behavior on number inputs. + // Otherwise it'll increase/decrease the value when user scrolls on the input control. + // See for details: https://github.com/mui/material-ui/issues/7960 + e.currentTarget.blur(); + }, }} placeholder="0" variant="standard" diff --git a/wormhole-connect/src/views/v2/Bridge/Routes/SingleRoute.tsx b/wormhole-connect/src/views/v2/Bridge/Routes/SingleRoute.tsx index 16dc4b102..6692eaec2 100644 --- a/wormhole-connect/src/views/v2/Bridge/Routes/SingleRoute.tsx +++ b/wormhole-connect/src/views/v2/Bridge/Routes/SingleRoute.tsx @@ -203,7 +203,7 @@ const SingleRoute = (props: Props) => { [quote?.eta, isFetchingQuote], ); - const showWarning = useMemo(() => { + const isManual = useMemo(() => { if (!props.route) { return false; } @@ -232,7 +232,7 @@ const SingleRoute = (props: Props) => { }, [props.error]); const warningMessage = useMemo(() => { - if (!showWarning) { + if (!isManual) { return null; } @@ -253,7 +253,7 @@ const SingleRoute = (props: Props) => { ); - }, [showWarning]); + }, [isManual]); const providerText = useMemo(() => { if (!sourceToken) { diff --git a/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx b/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx index 5e8af2fc5..a137a5795 100644 --- a/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; import Link from '@mui/material/Link'; import { makeStyles } from 'tss-react/mui'; @@ -11,11 +12,12 @@ import AlertBannerV2 from 'components/v2/AlertBanner'; import type { RootState } from 'store'; import { RouteState } from 'store/transferInput'; - import { routes } from '@wormhole-foundation/sdk'; -import { Typography } from '@mui/material'; const useStyles = makeStyles()((theme: any) => ({ + routes: { + width: '100%', + }, connectWallet: { display: 'flex', alignItems: 'center', @@ -31,6 +33,9 @@ const useStyles = makeStyles()((theme: any) => ({ width: '100%', }, otherRoutesToggle: { + display: 'block', + width: '100%', + textAlign: 'center', fontSize: 14, color: theme.palette.primary.main, textDecoration: 'none', @@ -39,17 +44,26 @@ const useStyles = makeStyles()((theme: any) => ({ textDecoration: 'underline', }, }, + routesWrapper: { + width: '100%', + }, + routesBlock: { + marginBottom: '16px', + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, })); type Props = { - sortedSupportedRoutes: RouteState[]; + routes: RouteState[]; selectedRoute?: string; onRouteChange: (route: string) => void; quotes: Record | undefined>; isFetchingQuotes: boolean; }; -const Routes = ({ sortedSupportedRoutes, ...props }: Props) => { +const Routes = ({ ...props }: Props) => { const { classes } = useStyles(); const [showAll, setShowAll] = useState(false); @@ -76,18 +90,18 @@ const Routes = ({ sortedSupportedRoutes, ...props }: Props) => { const renderRoutes = useMemo(() => { if (showAll) { - return sortedSupportedRoutes; + return props.routes; } - const selectedRoute = sortedSupportedRoutes.find( + const selectedRoute = props.routes.find( (route) => route.name === props.selectedRoute, ); - return selectedRoute ? [selectedRoute] : sortedSupportedRoutes.slice(0, 1); - }, [showAll, sortedSupportedRoutes]); + return selectedRoute ? [selectedRoute] : props.routes.slice(0, 1); + }, [showAll, props.routes]); const fastestRoute = useMemo(() => { - return sortedSupportedRoutes.reduce( + return props.routes.reduce( (fastest, route) => { const quote = props.quotes[route.name]; if (!quote || !quote.success) return fastest; @@ -104,10 +118,10 @@ const Routes = ({ sortedSupportedRoutes, ...props }: Props) => { }, { name: '', eta: Infinity }, ); - }, [sortedSupportedRoutes, props.quotes]); + }, [routes, props.quotes]); const cheapestRoute = useMemo(() => { - return sortedSupportedRoutes.reduce( + return props.routes.reduce( (cheapest, route) => { const quote = props.quotes[route.name]; const rc = config.routes.get(route.name); @@ -123,7 +137,7 @@ const Routes = ({ sortedSupportedRoutes, ...props }: Props) => { }, { name: '', amountOut: 0n }, ); - }, [sortedSupportedRoutes, props.quotes]); + }, [routes, props.quotes]); if (walletsConnected && supportedRoutes.length === 0 && Number(amount) > 0) { return ( @@ -137,7 +151,7 @@ const Routes = ({ sortedSupportedRoutes, ...props }: Props) => { } if (supportedRoutes.length === 0 || !walletsConnected) { - return <>; + return null; } if (walletsConnected && !(Number(amount) > 0)) { @@ -188,7 +202,7 @@ const Routes = ({ sortedSupportedRoutes, ...props }: Props) => { /> ); })} - {sortedSupportedRoutes.length > 1 && ( + {props.routes.length > 1 && ( setShowAll((prev) => !prev)} className={classes.otherRoutesToggle} diff --git a/wormhole-connect/src/views/v2/Bridge/index.tsx b/wormhole-connect/src/views/v2/Bridge/index.tsx index 06f735e2a..decdad0a0 100644 --- a/wormhole-connect/src/views/v2/Bridge/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/index.tsx @@ -126,7 +126,7 @@ const Bridge = () => { validations, } = useSelector((state: RootState) => state.transferInput); - const { sortedRoutes, allSupportedRoutes, quotesMap, isFetchingQuotes } = + const { sortedRoutes, quotesMap, isFetchingQuotes } = useSortedRoutesWithQuotes(); // Compute and set source tokens @@ -414,7 +414,7 @@ const Bridge = () => { Date: Mon, 16 Sep 2024 22:06:51 +0400 Subject: [PATCH 3/9] refactor(routes): unify route and input errors --- .../src/hooks/useAmountValidation.ts | 94 +++++++++++++++++++ .../src/hooks/useRoutesQuotesBulk.ts | 4 +- .../src/hooks/useSortedRoutesWithQuotes.ts | 14 ++- .../src/views/v2/Bridge/AmountInput/index.tsx | 9 +- .../src/views/v2/Bridge/index.tsx | 40 +++++--- 5 files changed, 135 insertions(+), 26 deletions(-) create mode 100644 wormhole-connect/src/hooks/useAmountValidation.ts diff --git a/wormhole-connect/src/hooks/useAmountValidation.ts b/wormhole-connect/src/hooks/useAmountValidation.ts new file mode 100644 index 000000000..9890623ff --- /dev/null +++ b/wormhole-connect/src/hooks/useAmountValidation.ts @@ -0,0 +1,94 @@ +import { amount as sdkAmount, routes } from "@wormhole-foundation/sdk"; +import { useMemo } from "react"; +import { useSelector } from "react-redux"; +import { QuoteResult } from "routes/operator"; +import { RootState } from "store"; +import { RouteState } from "store/transferInput"; + +type HookReturn = { + error?: string; + warning?: string; +}; + +type Props = { + balance?: string | null; + routes: RouteState[]; + quotesMap: Record; + tokenSymbol: string; +}; + +export const useAmountValidation = (props: Props): HookReturn => { + const { amount } = useSelector((state: RootState) => state.transferInput); + + // Min amount available + const minAmount = useMemo(() => Object.values(props.quotesMap).reduce((minAmount, quoteResult) => { + if (quoteResult?.success) { + return minAmount; + } + + const minAmountError = quoteResult?.error as routes.MinAmountError; + + if (!minAmountError?.min) { + return minAmount; + } + + if (!minAmount) { + return minAmountError.min; + } + + const minAmountNum = parseFloat(minAmountError.min.amount); + const existingMin = parseFloat(minAmount.amount); + if (minAmountNum < existingMin) { + return minAmountError.min; + } else { + return minAmount; + } + }, undefined as sdkAmount.Amount | undefined), [props.quotesMap]); + + const allRoutesFailed = useMemo(() => props.routes.every(route => !props.quotesMap[route.name]?.success), [props.routes, props.quotesMap]); + + if (amount === '') { + return {}; + } + + const numAmount = Number.parseFloat(amount); + // Input errors + if (Number.isNaN(numAmount)) {return { + error: 'Amount must be a number.', + };} + if (numAmount <= 0) {return { + error: 'Amount must be greater than 0.', + };} + + // Balance errors + if (props.balance) { + const balanceNum = Number.parseFloat(props.balance.replace(',', '')); + if (numAmount > balanceNum) {return { + error: 'Amount exceeds available balance.', + };} + } + + // All quotes fail. + if (allRoutesFailed) { + if (minAmount) { + const amountDisplay = sdkAmount.display(minAmount); + return { + error: `Amount too small (min ~${amountDisplay} ${props.tokenSymbol})`, + }; + } else { + return { + error: 'No routes found for this transaction.', + }; + }; + } + + // MinQuote warnings information + if (minAmount) { + const amountDisplay = sdkAmount.display(minAmount); + return { + warning: `More routes available for amounts exceeding ${amountDisplay} ${props.tokenSymbol}`, + }; + } + + return {}; +}; \ No newline at end of file diff --git a/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts b/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts index e41c03fea..dd236965e 100644 --- a/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts +++ b/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts @@ -9,7 +9,7 @@ import { circle, amount, } from '@wormhole-foundation/sdk'; -import { QuoteParams } from 'routes/operator'; +import { QuoteParams, QuoteResult } from 'routes/operator'; import { calculateUSDPriceRaw } from 'utils'; import config from 'config'; @@ -23,8 +23,6 @@ type Params = { nativeGas: number; }; -type QuoteResult = routes.QuoteResult; - type HookReturn = { quotesMap: Record; isFetching: boolean; diff --git a/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts b/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts index 3aaa85724..6a4ff9bc4 100644 --- a/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts +++ b/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts @@ -10,7 +10,8 @@ type Quote = routes.Quote< routes.Options, routes.ValidatedTransferParams >; -type RouteWithQuote = { + +export type RouteWithQuote = { route: RouteState; quote: Quote; }; @@ -18,6 +19,7 @@ type RouteWithQuote = { type HookReturn = { allSupportedRoutes: RouteState[]; sortedRoutes: RouteState[]; + sortedRoutesWithQuotes: RouteWithQuote[]; quotesMap: ReturnType['quotesMap']; isFetchingQuotes: boolean; }; @@ -64,7 +66,7 @@ export const useSortedRoutesWithQuotes = (): HookReturn => { }, [supportedRoutes, quotesMap]); // Only routes with quotes are sorted. - const sortedRoutes = useMemo(() => { + const sortedRoutesWithQuotes = useMemo(() => { return [...routesWithQuotes] .sort((routeA, routeB) => { const routeConfigA = config.routes.get(routeA.route.name); @@ -102,17 +104,19 @@ export const useSortedRoutesWithQuotes = (): HookReturn => { // Don't swap when routes match by all criteria or don't have quotas return 0; - }) - .map((routeWithQuote) => routeWithQuote.route); + }); }, [routesWithQuotes]); + const sortedRoutes = useMemo(() => sortedRoutesWithQuotes.map(r => r.route), [sortedRoutesWithQuotes]); + return useMemo( () => ({ allSupportedRoutes: supportedRoutes, sortedRoutes, + sortedRoutesWithQuotes, quotesMap, isFetchingQuotes: isFetching, }), - [supportedRoutes, sortedRoutes, quotesMap, isFetching], + [supportedRoutes, sortedRoutesWithQuotes, quotesMap, isFetching], ); }; diff --git a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx index 050db3e85..b398c22b9 100644 --- a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx @@ -73,6 +73,7 @@ const useStyles = makeStyles()((theme) => ({ type Props = { supportedSourceTokens: Array; error?: string; + warning?: string; }; /** @@ -208,10 +209,10 @@ const AmountInput = (props: Props) => {
diff --git a/wormhole-connect/src/views/v2/Bridge/index.tsx b/wormhole-connect/src/views/v2/Bridge/index.tsx index decdad0a0..b4f5ec51a 100644 --- a/wormhole-connect/src/views/v2/Bridge/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/index.tsx @@ -42,6 +42,8 @@ import { useSortedRoutesWithQuotes } from 'hooks/useSortedRoutesWithQuotes'; import { useFetchTokenPrices } from 'hooks/useFetchTokenPrices'; import type { Chain } from '@wormhole-foundation/sdk'; +import { useAmountValidation } from 'hooks/useAmountValidation'; +import useGetTokenBalances from 'hooks/useGetTokenBalances'; const useStyles = makeStyles()((theme) => ({ assetPickerContainer: { @@ -126,7 +128,7 @@ const Bridge = () => { validations, } = useSelector((state: RootState) => state.transferInput); - const { sortedRoutes, quotesMap, isFetchingQuotes } = + const { allSupportedRoutes, sortedRoutes, sortedRoutesWithQuotes, quotesMap, isFetchingQuotes } = useSortedRoutesWithQuotes(); // Compute and set source tokens @@ -151,27 +153,23 @@ const Bridge = () => { // 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(() => { - const validRoutes = sortedRoutes.filter((rs) => rs.supported); + const validRoutes = sortedRoutesWithQuotes.filter((rs) => rs.route.supported); - const routesWithSuccessfulQuote = validRoutes.filter( - (rs) => quotesMap[rs.name]?.success, - ); - - if (routesWithSuccessfulQuote.length === 0) { + if (validRoutes.length === 0) { setSelectedRoute(''); } else { - const autoselectedRoute = route || routesWithSuccessfulQuote[0]?.name; + const autoselectedRoute = route || validRoutes[0].route.name; // avoids overwriting selected route if (!autoselectedRoute || !!selectedRoute) return; - const routeState = validRoutes?.find( - (rs) => rs.name === autoselectedRoute, + const routeData = validRoutes?.find( + (rs) => rs.route.name === autoselectedRoute, ); - if (routeState) setSelectedRoute(routeState.name); + if (routeData) setSelectedRoute(routeData.route.name); } - }, [route, sortedRoutes, quotesMap]); + }, [route, sortedRoutesWithQuotes]); // Pre-fetch available routes useFetchSupportedRoutes(); @@ -185,6 +183,20 @@ const Bridge = () => { // Fetch token prices useFetchTokenPrices(); + const { balances, isFetching: isFetchingBalances } = useGetTokenBalances( + sendingWallet?.address || '', + sourceChain, + sourceToken ? [config.tokens[sourceToken]]: [], + ); + + // Validate amount + const amountValidation = useAmountValidation({ + balance: balances[sourceToken]?.balance, + routes: allSupportedRoutes, + quotesMap, + tokenSymbol: config.tokens[sourceToken]?.symbol ?? '', + }); + // Get input validation result const isValid = useMemo(() => isTransferValid(validations), [validations]); @@ -412,13 +424,13 @@ const Bridge = () => { {sourceAssetPicker} {destAssetPicker} - + {walletConnector} {showReviewTransactionButton ? reviewTransactionButton : null} From 9c650f2f1b1858d9961643aa41e90e2b40dc2b2a Mon Sep 17 00:00:00 2001 From: Armen Nikoyan Date: Mon, 16 Sep 2024 22:21:37 +0400 Subject: [PATCH 4/9] fix: linting fixes --- .../src/hooks/useAmountValidation.ts | 87 +++++++++++-------- .../src/hooks/useSortedRoutesWithQuotes.ts | 68 ++++++++------- .../src/views/v2/Bridge/AmountInput/index.tsx | 14 ++- .../src/views/v2/Bridge/index.tsx | 21 +++-- 4 files changed, 113 insertions(+), 77 deletions(-) diff --git a/wormhole-connect/src/hooks/useAmountValidation.ts b/wormhole-connect/src/hooks/useAmountValidation.ts index 9890623ff..20499cd6f 100644 --- a/wormhole-connect/src/hooks/useAmountValidation.ts +++ b/wormhole-connect/src/hooks/useAmountValidation.ts @@ -1,9 +1,9 @@ -import { amount as sdkAmount, routes } from "@wormhole-foundation/sdk"; -import { useMemo } from "react"; -import { useSelector } from "react-redux"; -import { QuoteResult } from "routes/operator"; -import { RootState } from "store"; -import { RouteState } from "store/transferInput"; +import { amount as sdkAmount, routes } from '@wormhole-foundation/sdk'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { QuoteResult } from 'routes/operator'; +import { RootState } from 'store'; +import { RouteState } from 'store/transferInput'; type HookReturn = { error?: string; @@ -21,31 +21,38 @@ export const useAmountValidation = (props: Props): HookReturn => { const { amount } = useSelector((state: RootState) => state.transferInput); // Min amount available - const minAmount = useMemo(() => Object.values(props.quotesMap).reduce((minAmount, quoteResult) => { - if (quoteResult?.success) { - return minAmount; - } + const minAmount = useMemo( + () => + Object.values(props.quotesMap).reduce((minAmount, quoteResult) => { + if (quoteResult?.success) { + return minAmount; + } - const minAmountError = quoteResult?.error as routes.MinAmountError; + const minAmountError = quoteResult?.error as routes.MinAmountError; - if (!minAmountError?.min) { - return minAmount; - } + if (!minAmountError?.min) { + return minAmount; + } - if (!minAmount) { - return minAmountError.min; - } + if (!minAmount) { + return minAmountError.min; + } - const minAmountNum = parseFloat(minAmountError.min.amount); - const existingMin = parseFloat(minAmount.amount); - if (minAmountNum < existingMin) { - return minAmountError.min; - } else { - return minAmount; - } - }, undefined as sdkAmount.Amount | undefined), [props.quotesMap]); + const minAmountNum = parseFloat(minAmountError.min.amount); + const existingMin = parseFloat(minAmount.amount); + if (minAmountNum < existingMin) { + return minAmountError.min; + } else { + return minAmount; + } + }, undefined as sdkAmount.Amount | undefined), + [props.quotesMap], + ); - const allRoutesFailed = useMemo(() => props.routes.every(route => !props.quotesMap[route.name]?.success), [props.routes, props.quotesMap]); + const allRoutesFailed = useMemo( + () => props.routes.every((route) => !props.quotesMap[route.name]?.success), + [props.routes, props.quotesMap], + ); if (amount === '') { return {}; @@ -53,19 +60,25 @@ export const useAmountValidation = (props: Props): HookReturn => { const numAmount = Number.parseFloat(amount); // Input errors - if (Number.isNaN(numAmount)) {return { - error: 'Amount must be a number.', - };} - if (numAmount <= 0) {return { - error: 'Amount must be greater than 0.', - };} + if (Number.isNaN(numAmount)) { + return { + error: 'Amount must be a number.', + }; + } + if (numAmount <= 0) { + return { + error: 'Amount must be greater than 0.', + }; + } // Balance errors if (props.balance) { const balanceNum = Number.parseFloat(props.balance.replace(',', '')); - if (numAmount > balanceNum) {return { - error: 'Amount exceeds available balance.', - };} + if (numAmount > balanceNum) { + return { + error: 'Amount exceeds available balance.', + }; + } } // All quotes fail. @@ -79,7 +92,7 @@ export const useAmountValidation = (props: Props): HookReturn => { return { error: 'No routes found for this transaction.', }; - }; + } } // MinQuote warnings information @@ -91,4 +104,4 @@ export const useAmountValidation = (props: Props): HookReturn => { } return {}; -}; \ No newline at end of file +}; diff --git a/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts b/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts index 6a4ff9bc4..80411a954 100644 --- a/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts +++ b/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts @@ -67,47 +67,49 @@ export const useSortedRoutesWithQuotes = (): HookReturn => { // Only routes with quotes are sorted. const sortedRoutesWithQuotes = useMemo(() => { - return [...routesWithQuotes] - .sort((routeA, routeB) => { - const routeConfigA = config.routes.get(routeA.route.name); - const routeConfigB = config.routes.get(routeB.route.name); + return [...routesWithQuotes].sort((routeA, routeB) => { + const routeConfigA = config.routes.get(routeA.route.name); + const routeConfigB = config.routes.get(routeB.route.name); - // 1. Prioritize automatic routes - if (routeConfigA.AUTOMATIC_DEPOSIT && !routeConfigB.AUTOMATIC_DEPOSIT) { - return -1; - } else if ( - !routeConfigA.AUTOMATIC_DEPOSIT && - routeConfigB.AUTOMATIC_DEPOSIT - ) { - return 1; - } + // 1. Prioritize automatic routes + if (routeConfigA.AUTOMATIC_DEPOSIT && !routeConfigB.AUTOMATIC_DEPOSIT) { + return -1; + } else if ( + !routeConfigA.AUTOMATIC_DEPOSIT && + routeConfigB.AUTOMATIC_DEPOSIT + ) { + return 1; + } - // 2. Prioritize estimated time - if (routeA.quote.eta && routeB.quote.eta) { - if (routeA.quote.eta > routeB.quote.eta) { - return 1; - } else if (routeA.quote.eta < routeB.quote.eta) { - return -1; - } + // 2. Prioritize estimated time + if (routeA.quote.eta && routeB.quote.eta) { + if (routeA.quote.eta > routeB.quote.eta) { + return 1; + } else if (routeA.quote.eta < routeB.quote.eta) { + return -1; } + } - // 3. Compare relay fees - if (routeA.quote.relayFee && routeB.quote.relayFee) { - const relayFeeA = sdkAmount.whole(routeA.quote.relayFee.amount); - const relayFeeB = sdkAmount.whole(routeB.quote.relayFee.amount); - if (relayFeeA > relayFeeB) { - return 1; - } else if (relayFeeA < relayFeeB) { - return -1; - } + // 3. Compare relay fees + if (routeA.quote.relayFee && routeB.quote.relayFee) { + const relayFeeA = sdkAmount.whole(routeA.quote.relayFee.amount); + const relayFeeB = sdkAmount.whole(routeB.quote.relayFee.amount); + if (relayFeeA > relayFeeB) { + return 1; + } else if (relayFeeA < relayFeeB) { + return -1; } + } - // Don't swap when routes match by all criteria or don't have quotas - return 0; - }); + // Don't swap when routes match by all criteria or don't have quotas + return 0; + }); }, [routesWithQuotes]); - const sortedRoutes = useMemo(() => sortedRoutesWithQuotes.map(r => r.route), [sortedRoutesWithQuotes]); + const sortedRoutes = useMemo( + () => sortedRoutesWithQuotes.map((r) => r.route), + [sortedRoutesWithQuotes], + ); return useMemo( () => ({ diff --git a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx index b398c22b9..6bf698505 100644 --- a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx @@ -1,4 +1,12 @@ -import React, { ChangeEventHandler, ComponentProps, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + ChangeEventHandler, + ComponentProps, + memo, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; import { useDebouncedCallback } from 'use-debounce'; @@ -212,7 +220,9 @@ const AmountInput = (props: Props) => { error={!!props.error} content={props.error || props.warning} show={!!props.error || !!props.warning} - color={props.error ? theme.palette.error.light : theme.palette.grey.A400} + color={ + props.error ? theme.palette.error.light : theme.palette.grey.A400 + } className={classes.inputError} />
diff --git a/wormhole-connect/src/views/v2/Bridge/index.tsx b/wormhole-connect/src/views/v2/Bridge/index.tsx index b4f5ec51a..c78faa132 100644 --- a/wormhole-connect/src/views/v2/Bridge/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/index.tsx @@ -128,8 +128,13 @@ const Bridge = () => { validations, } = useSelector((state: RootState) => state.transferInput); - const { allSupportedRoutes, sortedRoutes, sortedRoutesWithQuotes, quotesMap, isFetchingQuotes } = - useSortedRoutesWithQuotes(); + const { + allSupportedRoutes, + sortedRoutes, + sortedRoutesWithQuotes, + quotesMap, + isFetchingQuotes, + } = useSortedRoutesWithQuotes(); // Compute and set source tokens const { isFetching: isFetchingSupportedSourceTokens } = @@ -153,7 +158,9 @@ const Bridge = () => { // 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(() => { - const validRoutes = sortedRoutesWithQuotes.filter((rs) => rs.route.supported); + const validRoutes = sortedRoutesWithQuotes.filter( + (rs) => rs.route.supported, + ); if (validRoutes.length === 0) { setSelectedRoute(''); @@ -186,7 +193,7 @@ const Bridge = () => { const { balances, isFetching: isFetchingBalances } = useGetTokenBalances( sendingWallet?.address || '', sourceChain, - sourceToken ? [config.tokens[sourceToken]]: [], + sourceToken ? [config.tokens[sourceToken]] : [], ); // Validate amount @@ -424,7 +431,11 @@ const Bridge = () => { {sourceAssetPicker} {destAssetPicker} - + Date: Mon, 16 Sep 2024 23:12:52 +0400 Subject: [PATCH 5/9] fix(routes): loading screen on token changes --- .../src/views/v2/Bridge/AmountInput/index.tsx | 2 +- .../src/views/v2/Bridge/Routes/index.tsx | 24 +++++++------------ .../src/views/v2/Bridge/index.tsx | 8 +++++-- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx index 6bf698505..995a57558 100644 --- a/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/AmountInput/index.tsx @@ -26,7 +26,7 @@ import { setAmount } from 'store/transferInput'; import type { TokenConfig } from 'config/types'; import type { RootState } from 'store'; -const INPUT_DEBOUNCE = 300; +const INPUT_DEBOUNCE = 500; const DebouncedTextField = memo( ({ diff --git a/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx b/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx index a137a5795..8f55ea8f7 100644 --- a/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx @@ -13,11 +13,9 @@ import AlertBannerV2 from 'components/v2/AlertBanner'; import type { RootState } from 'store'; import { RouteState } from 'store/transferInput'; import { routes } from '@wormhole-foundation/sdk'; +import { CircularProgress } from '@mui/material'; const useStyles = makeStyles()((theme: any) => ({ - routes: { - width: '100%', - }, connectWallet: { display: 'flex', alignItems: 'center', @@ -44,15 +42,6 @@ const useStyles = makeStyles()((theme: any) => ({ textDecoration: 'underline', }, }, - routesWrapper: { - width: '100%', - }, - routesBlock: { - marginBottom: '16px', - display: 'flex', - flexDirection: 'column', - gap: '16px', - }, })); type Props = { @@ -60,7 +49,8 @@ type Props = { selectedRoute?: string; onRouteChange: (route: string) => void; quotes: Record | undefined>; - isFetchingQuotes: boolean; + isLoading: boolean; + hasError: boolean; }; const Routes = ({ ...props }: Props) => { @@ -150,10 +140,14 @@ const Routes = ({ ...props }: Props) => { ); } - if (supportedRoutes.length === 0 || !walletsConnected) { + if (supportedRoutes.length === 0 || !walletsConnected || props.hasError) { return null; } + if (props.isLoading) { + return ; + } + if (walletsConnected && !(Number(amount) > 0)) { return ( @@ -198,7 +192,7 @@ const Routes = ({ ...props }: Props) => { isOnlyChoice={supportedRoutes.length === 1} onSelect={props.onRouteChange} quote={quote} - isFetchingQuote={props.isFetchingQuotes} + isFetchingQuote={props.isLoading} /> ); })} diff --git a/wormhole-connect/src/views/v2/Bridge/index.tsx b/wormhole-connect/src/views/v2/Bridge/index.tsx index c78faa132..0b6ac5461 100644 --- a/wormhole-connect/src/views/v2/Bridge/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/index.tsx @@ -385,6 +385,8 @@ const Bridge = () => { ); }, [sourceChain, destChain, sendingWallet, receivingWallet]); + const hasError = !!amountValidation.error; + const showReviewTransactionButton = sourceChain && sourceToken && @@ -393,7 +395,8 @@ const Bridge = () => { sendingWallet.address && receivingWallet.address && selectedRoute && - Number(amount) > 0; + Number(amount) > 0 && + !hasError; const supportedRouteSelected = useMemo( () => @@ -441,7 +444,8 @@ const Bridge = () => { selectedRoute={selectedRoute} onRouteChange={setSelectedRoute} quotes={quotesMap} - isFetchingQuotes={isFetchingQuotes || isFetchingBalances} + isLoading={isFetchingQuotes || isFetchingBalances} + hasError={hasError} /> {walletConnector} {showReviewTransactionButton ? reviewTransactionButton : null} From 5256fc93b73e5597f2009af13c79942b16637746 Mon Sep 17 00:00:00 2001 From: Armen Nikoyan Date: Mon, 16 Sep 2024 23:23:10 +0400 Subject: [PATCH 6/9] fix(errors): loading states --- wormhole-connect/src/hooks/useAmountValidation.ts | 4 +++- wormhole-connect/src/views/v2/Bridge/index.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/wormhole-connect/src/hooks/useAmountValidation.ts b/wormhole-connect/src/hooks/useAmountValidation.ts index 20499cd6f..b379fcc6b 100644 --- a/wormhole-connect/src/hooks/useAmountValidation.ts +++ b/wormhole-connect/src/hooks/useAmountValidation.ts @@ -15,6 +15,7 @@ type Props = { routes: RouteState[]; quotesMap: Record; tokenSymbol: string; + isLoading: boolean; }; export const useAmountValidation = (props: Props): HookReturn => { @@ -54,7 +55,8 @@ export const useAmountValidation = (props: Props): HookReturn => { [props.routes, props.quotesMap], ); - if (amount === '') { + // Don't show errors when no amount is set or it's loading + if (amount === '' || props.isLoading) { return {}; } diff --git a/wormhole-connect/src/views/v2/Bridge/index.tsx b/wormhole-connect/src/views/v2/Bridge/index.tsx index 0b6ac5461..1de401fc0 100644 --- a/wormhole-connect/src/views/v2/Bridge/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/index.tsx @@ -202,6 +202,7 @@ const Bridge = () => { routes: allSupportedRoutes, quotesMap, tokenSymbol: config.tokens[sourceToken]?.symbol ?? '', + isLoading: isFetchingBalances || isFetchingQuotes, }); // Get input validation result From 8a7dab00ba0740cc5572f10a8303acbc559da1f9 Mon Sep 17 00:00:00 2001 From: Armen Nikoyan Date: Tue, 17 Sep 2024 01:27:48 +0400 Subject: [PATCH 7/9] fix: re-render optimisation and PR suggestions --- .../src/hooks/useAmountValidation.ts | 17 +++---- .../src/hooks/useRoutesQuotesBulk.ts | 2 +- .../src/hooks/useSortedRoutesWithQuotes.ts | 45 ++++++++++--------- 3 files changed, 31 insertions(+), 33 deletions(-) diff --git a/wormhole-connect/src/hooks/useAmountValidation.ts b/wormhole-connect/src/hooks/useAmountValidation.ts index b379fcc6b..32e27ecc9 100644 --- a/wormhole-connect/src/hooks/useAmountValidation.ts +++ b/wormhole-connect/src/hooks/useAmountValidation.ts @@ -29,8 +29,9 @@ export const useAmountValidation = (props: Props): HookReturn => { return minAmount; } + // For some weird reason error instanceof MinAmountError returns false + // Workaround, to check if quoteResult.error is indeed instance of MinAmountError const minAmountError = quoteResult?.error as routes.MinAmountError; - if (!minAmountError?.min) { return minAmount; } @@ -39,8 +40,8 @@ export const useAmountValidation = (props: Props): HookReturn => { return minAmountError.min; } - const minAmountNum = parseFloat(minAmountError.min.amount); - const existingMin = parseFloat(minAmount.amount); + const minAmountNum = BigInt(minAmountError.min.amount); + const existingMin = BigInt(minAmount.amount); if (minAmountNum < existingMin) { return minAmountError.min; } else { @@ -55,23 +56,19 @@ export const useAmountValidation = (props: Props): HookReturn => { [props.routes, props.quotesMap], ); + const numAmount = Number.parseFloat(amount); + // Don't show errors when no amount is set or it's loading - if (amount === '' || props.isLoading) { + if (!amount || !numAmount || props.isLoading) { return {}; } - const numAmount = Number.parseFloat(amount); // Input errors if (Number.isNaN(numAmount)) { return { error: 'Amount must be a number.', }; } - if (numAmount <= 0) { - return { - error: 'Amount must be greater than 0.', - }; - } // Balance errors if (props.balance) { diff --git a/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts b/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts index dd236965e..d8fe24b42 100644 --- a/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts +++ b/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts @@ -52,7 +52,7 @@ const useRoutesQuotesBulk = (routes: string[], params: Params): HookReturn => { !params.sourceToken || !params.destChain || !params.destToken || - !params.amount + !parseFloat(params.amount) ) { return; } diff --git a/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts b/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts index 80411a954..f17565ae0 100644 --- a/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts +++ b/wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import type { RootState } from 'store'; -import { routes, amount as sdkAmount } from '@wormhole-foundation/sdk'; +import { routes } from '@wormhole-foundation/sdk'; import useRoutesQuotesBulk from 'hooks/useRoutesQuotesBulk'; import config from 'config'; import { RouteState } from 'store/transferInput'; @@ -39,14 +39,22 @@ export const useSortedRoutesWithQuotes = (): HookReturn => { [supportedRoutes], ); - const { quotesMap, isFetching } = useRoutesQuotesBulk(supportedRoutesNames, { - amount, - sourceChain: fromChain, - sourceToken: token, - destChain: toChain, - destToken, - nativeGas: toNativeToken, - }); + const useQuotesBulkParams = useMemo( + () => ({ + amount, + sourceChain: fromChain, + sourceToken: token, + destChain: toChain, + destToken, + nativeGas: toNativeToken, + }), + [parseFloat(amount), fromChain, token, toChain, destToken, toNativeToken], + ); + + const { quotesMap, isFetching } = useRoutesQuotesBulk( + supportedRoutesNames, + useQuotesBulkParams, + ); const routesWithQuotes = useMemo(() => { return supportedRoutes @@ -90,19 +98,12 @@ export const useSortedRoutesWithQuotes = (): HookReturn => { } } - // 3. Compare relay fees - if (routeA.quote.relayFee && routeB.quote.relayFee) { - const relayFeeA = sdkAmount.whole(routeA.quote.relayFee.amount); - const relayFeeB = sdkAmount.whole(routeB.quote.relayFee.amount); - if (relayFeeA > relayFeeB) { - return 1; - } else if (relayFeeA < relayFeeB) { - return -1; - } - } - - // Don't swap when routes match by all criteria or don't have quotas - return 0; + // 3. Compare destination token amounts + const destAmountA = BigInt(routeA.quote.destinationToken.amount.amount); + const destAmountB = BigInt(routeB.quote.destinationToken.amount.amount); + // Note: Sort callback return strictly expects Number + // Returning BigInt results in TypeError + return Number(destAmountB - destAmountA); }); }, [routesWithQuotes]); From 5c57c386f8283493c7d3c24691eb3909f8783d5c Mon Sep 17 00:00:00 2001 From: Armen Nikoyan Date: Tue, 17 Sep 2024 18:38:26 +0400 Subject: [PATCH 8/9] fix: PR suggestions --- wormhole-connect/src/hooks/useAmountValidation.ts | 14 ++++++-------- wormhole-connect/src/utils/sdkv2.ts | 6 ++++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/wormhole-connect/src/hooks/useAmountValidation.ts b/wormhole-connect/src/hooks/useAmountValidation.ts index 32e27ecc9..dff144eda 100644 --- a/wormhole-connect/src/hooks/useAmountValidation.ts +++ b/wormhole-connect/src/hooks/useAmountValidation.ts @@ -1,9 +1,10 @@ -import { amount as sdkAmount, routes } from '@wormhole-foundation/sdk'; +import { amount as sdkAmount } from '@wormhole-foundation/sdk'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { QuoteResult } from 'routes/operator'; import { RootState } from 'store'; import { RouteState } from 'store/transferInput'; +import { isMinAmountError } from 'utils/sdkv2'; type HookReturn = { error?: string; @@ -29,21 +30,18 @@ export const useAmountValidation = (props: Props): HookReturn => { return minAmount; } - // For some weird reason error instanceof MinAmountError returns false - // Workaround, to check if quoteResult.error is indeed instance of MinAmountError - const minAmountError = quoteResult?.error as routes.MinAmountError; - if (!minAmountError?.min) { + if (!isMinAmountError(quoteResult?.error)) { return minAmount; } if (!minAmount) { - return minAmountError.min; + return quoteResult.error.min; } - const minAmountNum = BigInt(minAmountError.min.amount); + const minAmountNum = BigInt(quoteResult.error.min.amount); const existingMin = BigInt(minAmount.amount); if (minAmountNum < existingMin) { - return minAmountError.min; + return quoteResult.error.min; } else { return minAmount; } diff --git a/wormhole-connect/src/utils/sdkv2.ts b/wormhole-connect/src/utils/sdkv2.ts index 2d5687eb1..50ae8ff88 100644 --- a/wormhole-connect/src/utils/sdkv2.ts +++ b/wormhole-connect/src/utils/sdkv2.ts @@ -431,3 +431,9 @@ const parseNttReceipt = ( relayerFee: undefined, // TODO: how to get? }; }; + +export const isMinAmountError = ( + error?: Error, +): error is routes.MinAmountError => { + return !!(error as routes.MinAmountError)?.min; +}; From 34ccb42d20f44e662a98d00f8f73d49a77660a87 Mon Sep 17 00:00:00 2001 From: Armen Nikoyan Date: Tue, 17 Sep 2024 23:20:19 +0400 Subject: [PATCH 9/9] fix(type-assertion): strickten MinAmountError type checker method --- wormhole-connect/src/utils/sdkv2.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/wormhole-connect/src/utils/sdkv2.ts b/wormhole-connect/src/utils/sdkv2.ts index 50ae8ff88..ec6781fca 100644 --- a/wormhole-connect/src/utils/sdkv2.ts +++ b/wormhole-connect/src/utils/sdkv2.ts @@ -432,8 +432,18 @@ const parseNttReceipt = ( }; }; +const isAmount = (amount: any): amount is amount.Amount => { + return ( + typeof amount === 'object' && + typeof amount.amount === 'string' && + typeof amount.decimals === 'number' + ); +}; + +// Warning: any changes to this function can make TS unhappy export const isMinAmountError = ( error?: Error, ): error is routes.MinAmountError => { - return !!(error as routes.MinAmountError)?.min; + const unsafeCastError = error as routes.MinAmountError; + return isAmount(unsafeCastError?.min?.amount); };