Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor AmountInput and Routes error handling. #2608

104 changes: 104 additions & 0 deletions wormhole-connect/src/hooks/useAmountValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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;
warning?: string;
};

type Props = {
balance?: string | null;
routes: RouteState[];
quotesMap: Record<string, QuoteResult | undefined>;
tokenSymbol: string;
isLoading: boolean;
};

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;
}

if (!isMinAmountError(quoteResult?.error)) {
return minAmount;
}

if (!minAmount) {
return quoteResult.error.min;
}

const minAmountNum = BigInt(quoteResult.error.min.amount);
const existingMin = BigInt(minAmount.amount);
if (minAmountNum < existingMin) {
return quoteResult.error.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 numAmount = Number.parseFloat(amount);

// Don't show errors when no amount is set or it's loading
if (!amount || !numAmount || props.isLoading) {
return {};
}

// Input errors
if (Number.isNaN(numAmount)) {
return {
error: 'Amount must be a number.',
};
}

// 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 {};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -92,4 +92,4 @@ const useAvailableRoutes = (): void => {
]);
};

export default useAvailableRoutes;
export default useFetchSupportedRoutes;
6 changes: 2 additions & 4 deletions wormhole-connect/src/hooks/useRoutesQuotesBulk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,8 +23,6 @@ type Params = {
nativeGas: number;
};

type QuoteResult = routes.QuoteResult<routes.Options>;

type HookReturn = {
quotesMap: Record<string, QuoteResult | undefined>;
isFetching: boolean;
Expand Down Expand Up @@ -54,7 +52,7 @@ const useRoutesQuotesBulk = (routes: string[], params: Params): HookReturn => {
!params.sourceToken ||
!params.destChain ||
!params.destToken ||
!params.amount
!parseFloat(params.amount)
) {
return;
}
Expand Down
125 changes: 125 additions & 0 deletions wormhole-connect/src/hooks/useSortedRoutesWithQuotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import type { RootState } from 'store';
import { routes } 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<routes.Options>
>;

export type RouteWithQuote = {
route: RouteState;
quote: Quote;
};

type HookReturn = {
allSupportedRoutes: RouteState[];
sortedRoutes: RouteState[];
sortedRoutesWithQuotes: RouteWithQuote[];
quotesMap: ReturnType<typeof useRoutesQuotesBulk>['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 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
.map((route) => {
const quote = quotesMap[route.name];
if (quote?.success) {
return {
route,
quote,
};
} else {
return undefined;
}
})
.filter(Boolean) as RouteWithQuote[];
artursapek marked this conversation as resolved.
Show resolved Hide resolved
// Safe to cast, as falsy values are filtered
}, [supportedRoutes, quotesMap]);

// 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);

// 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 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]);

const sortedRoutes = useMemo(
() => sortedRoutesWithQuotes.map((r) => r.route),
[sortedRoutesWithQuotes],
);

return useMemo(
() => ({
allSupportedRoutes: supportedRoutes,
sortedRoutes,
sortedRoutesWithQuotes,
quotesMap,
isFetchingQuotes: isFetching,
}),
[supportedRoutes, sortedRoutesWithQuotes, quotesMap, isFetching],
);
};
85 changes: 0 additions & 85 deletions wormhole-connect/src/hooks/useSortedSupportedRoutes.ts

This file was deleted.

16 changes: 16 additions & 0 deletions wormhole-connect/src/utils/sdkv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,19 @@ const parseNttReceipt = (
relayerFee: undefined, // TODO: how to get?
};
};

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 => {
const unsafeCastError = error as routes.MinAmountError;
return isAmount(unsafeCastError?.min?.amount);
};
4 changes: 2 additions & 2 deletions wormhole-connect/src/views/Bridge/RouteOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -353,7 +353,7 @@ function RouteOptions() {
(state: RootState) => state.transferInput,
);

useSupportedRoutes();
useFetchSupportedRoutes();

const onSelect = useCallback(
(value: string) => {
Expand Down
Loading
Loading