diff --git a/src/consts/args.ts b/src/consts/args.ts new file mode 100644 index 00000000..5b5970b5 --- /dev/null +++ b/src/consts/args.ts @@ -0,0 +1,5 @@ +export enum WARP_QUERY_PARAMS { + ORIGIN = 'origin', + DESTINATION = 'destination', + TOKEN = 'token', +} diff --git a/src/features/chains/ChainSelectField.tsx b/src/features/chains/ChainSelectField.tsx index 50e544e5..9a5a49f6 100644 --- a/src/features/chains/ChainSelectField.tsx +++ b/src/features/chains/ChainSelectField.tsx @@ -9,7 +9,7 @@ import { useChainDisplayName } from './hooks'; type Props = { name: string; label: string; - onChange?: (id: ChainName) => void; + onChange?: (id: ChainName, fieldName: string) => void; disabled?: boolean; customListItemField: ChainSearchMenuProps['customListItemField']; }; @@ -25,8 +25,7 @@ export function ChainSelectField({ name, label, onChange, disabled, customListIt // Reset other fields on chain change setFieldValue('recipient', ''); setFieldValue('amount', ''); - setFieldValue('tokenIndex', undefined); - if (onChange) onChange(chainName); + if (onChange) onChange(chainName, name); }; const [isModalOpen, setIsModalOpen] = useState(false); diff --git a/src/features/chains/utils.ts b/src/features/chains/utils.ts index be11c771..9e59ecca 100644 --- a/src/features/chains/utils.ts +++ b/src/features/chains/utils.ts @@ -62,3 +62,13 @@ export function getNumRoutesWithSelectedChain( data, }; } + +/** + * Return given chainName if it is valid, otherwise return undefined + */ +export function tryGetValidChainName( + chainName: string | null, + multiProvider: MultiProtocolProvider, +): string | undefined { + return chainName && multiProvider.tryGetChainName(chainName) ? chainName : undefined; +} diff --git a/src/features/tokens/TokenSelectField.tsx b/src/features/tokens/TokenSelectField.tsx index 17a4d690..fb8bd531 100644 --- a/src/features/tokens/TokenSelectField.tsx +++ b/src/features/tokens/TokenSelectField.tsx @@ -3,6 +3,7 @@ import { ChevronIcon } from '@hyperlane-xyz/widgets'; import { useField, useFormikContext } from 'formik'; import { useEffect, useState } from 'react'; import { TokenIcon } from '../../components/icons/TokenIcon'; + import { TransferFormValues } from '../transfer/types'; import { TokenListModal } from './TokenListModal'; import { getIndexForToken, getTokenByIndex, useWarpCore } from './hooks'; @@ -11,9 +12,10 @@ type Props = { name: string; disabled?: boolean; setIsNft: (value: boolean) => void; + onChangeToken: (addressOrDenom: string) => void; }; -export function TokenSelectField({ name, disabled, setIsNft }: Props) { +export function TokenSelectField({ name, disabled, setIsNft, onChangeToken }: Props) { const { values } = useFormikContext(); const [field, , helpers] = useField(name); const [isModalOpen, setIsModalOpen] = useState(false); @@ -24,29 +26,13 @@ export function TokenSelectField({ name, disabled, setIsNft }: Props) { const { origin, destination } = values; useEffect(() => { const tokensWithRoute = warpCore.getTokensForRoute(origin, destination); - let newFieldValue: number | undefined; - let newIsAutomatic: boolean; - // No tokens available for this route - if (tokensWithRoute.length === 0) { - newFieldValue = undefined; - newIsAutomatic = true; - } - // Exactly one found - else if (tokensWithRoute.length === 1) { - newFieldValue = getIndexForToken(warpCore, tokensWithRoute[0]); - newIsAutomatic = true; - // Multiple possibilities - } else { - newFieldValue = undefined; - newIsAutomatic = false; - } - helpers.setValue(newFieldValue); - setIsAutomaticSelection(newIsAutomatic); + setIsAutomaticSelection(tokensWithRoute.length <= 1); }, [warpCore, origin, destination, helpers]); const onSelectToken = (newToken: IToken) => { // Set the token address value in formik state helpers.setValue(getIndexForToken(warpCore, newToken)); + onChangeToken(newToken.addressOrDenom); // Update nft state in parent setIsNft(newToken.isNft()); }; diff --git a/src/features/tokens/hooks.ts b/src/features/tokens/hooks.ts index 21ed9593..48afbf12 100644 --- a/src/features/tokens/hooks.ts +++ b/src/features/tokens/hooks.ts @@ -43,3 +43,47 @@ export function tryFindToken( return null; } } + +function getTokenIndexFromChains( + warpCore: WarpCore, + addressOrDenom: string | null, + origin: string, + destination: string, +) { + // find routes + const tokensWithRoute = warpCore.getTokensForRoute(origin, destination); + // find provided token addressOrDenom + const queryToken = tokensWithRoute.find((token) => token.addressOrDenom === addressOrDenom); + + // if found return index + if (queryToken) return getIndexForToken(warpCore, queryToken); + // if tokens route has only one route return that index + else if (tokensWithRoute.length === 1) return getIndexForToken(warpCore, tokensWithRoute[0]); + // if 0 or more than 1 then return undefined + return undefined; +} + +export function getInitialTokenIndex( + warpCore: WarpCore, + addressOrDenom: string | null, + originQuery?: string, + destinationQuery?: string, +): number | undefined { + const firstToken = warpCore.tokens[0]; + const connectedToken = firstToken.connections?.[0]; + + // origin query and destination query is defined + if (originQuery && destinationQuery) + return getTokenIndexFromChains(warpCore, addressOrDenom, originQuery, destinationQuery); + + // if none of those are defined, use default values and pass token query + if (connectedToken) + return getTokenIndexFromChains( + warpCore, + addressOrDenom, + firstToken.chainName, + connectedToken.token.chainName, + ); + + return undefined; +} diff --git a/src/features/transfer/TransferTokenForm.tsx b/src/features/transfer/TransferTokenForm.tsx index 8ea7c252..83ea1e97 100644 --- a/src/features/transfer/TransferTokenForm.tsx +++ b/src/features/transfer/TransferTokenForm.tsx @@ -18,14 +18,16 @@ import { toast } from 'react-toastify'; import { ConnectAwareSubmitButton } from '../../components/buttons/ConnectAwareSubmitButton'; import { SolidButton } from '../../components/buttons/SolidButton'; import { TextField } from '../../components/input/TextField'; +import { WARP_QUERY_PARAMS } from '../../consts/args'; import { config } from '../../consts/config'; import { Color } from '../../styles/Color'; import { logger } from '../../utils/logger'; +import { getQueryParams, updateQueryParam } from '../../utils/queryParams'; import { ChainConnectionWarning } from '../chains/ChainConnectionWarning'; import { ChainSelectField } from '../chains/ChainSelectField'; import { ChainWalletWarning } from '../chains/ChainWalletWarning'; import { useChainDisplayName, useMultiProvider } from '../chains/hooks'; -import { getNumRoutesWithSelectedChain } from '../chains/utils'; +import { getNumRoutesWithSelectedChain, tryGetValidChainName } from '../chains/utils'; import { useIsAccountSanctioned } from '../sanctions/hooks/useIsAccountSanctioned'; import { useStore } from '../store'; import { SelectOrInputTokenIds } from '../tokens/SelectOrInputTokenIds'; @@ -36,7 +38,12 @@ import { useDestinationBalance, useOriginBalance, } from '../tokens/balances'; -import { getIndexForToken, getTokenByIndex, useWarpCore } from '../tokens/hooks'; +import { + getIndexForToken, + getInitialTokenIndex, + getTokenByIndex, + useWarpCore, +} from '../tokens/hooks'; import { RecipientConfirmationModal } from './RecipientConfirmationModal'; import { useFetchMaxAmount } from './maxAmount'; import { TransferFormValues } from './types'; @@ -111,7 +118,13 @@ export function TransferTokenForm() { ); } -function SwapChainsButton({ disabled }: { disabled?: boolean }) { +function SwapChainsButton({ + disabled, + onSwapChain, +}: { + disabled?: boolean; + onSwapChain: (origin: string, destination: string) => void; +}) { const { values, setFieldValue } = useFormikContext(); const { origin, destination } = values; @@ -120,8 +133,8 @@ function SwapChainsButton({ disabled }: { disabled?: boolean }) { setFieldValue('origin', destination); setFieldValue('destination', origin); // Reset other fields on chain change - setFieldValue('tokenIndex', undefined); setFieldValue('recipient', ''); + onSwapChain(destination, origin); }; return ( @@ -141,7 +154,7 @@ function SwapChainsButton({ disabled }: { disabled?: boolean }) { function ChainSelectSection({ isReview }: { isReview: boolean }) { const warpCore = useWarpCore(); - const { values } = useFormikContext(); + const { values, setFieldValue } = useFormikContext(); const originRouteCounts = useMemo(() => { return getNumRoutesWithSelectedChain(warpCore, values.origin, true); @@ -151,6 +164,36 @@ function ChainSelectSection({ isReview }: { isReview: boolean }) { return getNumRoutesWithSelectedChain(warpCore, values.destination, false); }, [values.destination, warpCore]); + const setTokenOnChainChange = (origin: string, destination: string) => { + const tokensWithRoute = warpCore.getTokensForRoute(origin, destination); + let newFieldValue: number | undefined; + if (tokensWithRoute.length === 1) { + const token = tokensWithRoute[0]; + newFieldValue = getIndexForToken(warpCore, token); + updateQueryParam(WARP_QUERY_PARAMS.TOKEN, token.addressOrDenom); + // Not found or Multiple possibilities + } else { + newFieldValue = undefined; + updateQueryParam(WARP_QUERY_PARAMS.TOKEN, undefined); + } + + setFieldValue('tokenIndex', newFieldValue); + }; + + const handleChange = (chainName: string, fieldName: string) => { + if (fieldName === WARP_QUERY_PARAMS.ORIGIN) + setTokenOnChainChange(chainName, values.destination); + else if (fieldName === WARP_QUERY_PARAMS.DESTINATION) + setTokenOnChainChange(values.origin, chainName); + updateQueryParam(fieldName, chainName); + }; + + const onSwapChain = (origin: string, destination: string) => { + updateQueryParam(WARP_QUERY_PARAMS.ORIGIN, origin); + updateQueryParam(WARP_QUERY_PARAMS.DESTINATION, destination); + setTokenOnChainChange(origin, destination); + }; + return (
- +
); @@ -179,12 +224,21 @@ function TokenSection({ setIsNft: (b: boolean) => void; isReview: boolean; }) { + const onChangeToken = (addressOrDenom: string) => { + updateQueryParam(WARP_QUERY_PARAMS.TOKEN, addressOrDenom); + }; + return (
- +
); } @@ -474,17 +528,36 @@ function WarningBanners() { function useFormInitialValues(): TransferFormValues { const warpCore = useWarpCore(); + const params = getQueryParams(); + + const originQuery = tryGetValidChainName( + params.get(WARP_QUERY_PARAMS.ORIGIN), + warpCore.multiProvider, + ); + const destinationQuery = tryGetValidChainName( + params.get(WARP_QUERY_PARAMS.DESTINATION), + warpCore.multiProvider, + ); + + const tokenIndex = getInitialTokenIndex( + warpCore, + params.get(WARP_QUERY_PARAMS.TOKEN), + originQuery, + destinationQuery, + ); + return useMemo(() => { const firstToken = warpCore.tokens[0]; const connectedToken = firstToken.connections?.[0]; + return { - origin: firstToken.chainName, - destination: connectedToken?.token?.chainName || '', - tokenIndex: getIndexForToken(warpCore, firstToken), + origin: originQuery ? originQuery : firstToken.chainName, + destination: destinationQuery ? destinationQuery : connectedToken?.token?.chainName || '', + tokenIndex: tokenIndex, amount: '', recipient: '', }; - }, [warpCore]); + }, [warpCore, destinationQuery, originQuery, tokenIndex]); } const insufficientFundsErrMsg = /insufficient.[funds|lamports]/i; diff --git a/src/utils/queryParams.ts b/src/utils/queryParams.ts new file mode 100644 index 00000000..f8ec12c0 --- /dev/null +++ b/src/utils/queryParams.ts @@ -0,0 +1,19 @@ +export function getQueryParams() { + return new URLSearchParams(window.location.search); +} + +export function updateQueryParam(key: string, value?: string | number) { + const params = getQueryParams(); // Get current query parameters + + if (value === undefined || value === null) { + // Remove the parameter if the value is undefined or null + params.delete(key); + } else { + // Add or update the parameter + params.set(key, value.toString()); + } + + // Update the browser's URL without reloading the page + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState({}, '', newUrl); +}