From 70ac1919609ade334d6890eaed33338951450885 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Thu, 23 Jan 2025 15:37:03 -0400 Subject: [PATCH 1/8] feat: sync chains with url --- src/features/transfer/TransferTokenForm.tsx | 34 ++++++++++++-- src/utils/queryParams.ts | 51 +++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 src/utils/queryParams.ts diff --git a/src/features/transfer/TransferTokenForm.tsx b/src/features/transfer/TransferTokenForm.tsx index 4318670f..fbf65436 100644 --- a/src/features/transfer/TransferTokenForm.tsx +++ b/src/features/transfer/TransferTokenForm.tsx @@ -12,6 +12,7 @@ import { } from '@hyperlane-xyz/widgets'; import BigNumber from 'bignumber.js'; import { Form, Formik, useFormikContext } from 'formik'; +import { useRouter } from 'next/router'; import { useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import { ConnectAwareSubmitButton } from '../../components/buttons/ConnectAwareSubmitButton'; @@ -20,6 +21,7 @@ import { TextField } from '../../components/input/TextField'; import { config } from '../../consts/config'; import { Color } from '../../styles/Color'; import { logger } from '../../utils/logger'; +import { useMultipleQueryParams, useSyncQueryParam } from '../../utils/queryParams'; import { ChainConnectionWarning } from '../chains/ChainConnectionWarning'; import { ChainSelectField } from '../chains/ChainSelectField'; import { ChainWalletWarning } from '../chains/ChainWalletWarning'; @@ -38,6 +40,12 @@ import { useRecipientBalanceWatcher } from './useBalanceWatcher'; import { useFeeQuotes } from './useFeeQuotes'; import { useTokenTransfer } from './useTokenTransfer'; +enum WARP_QUERY_PARAMS { + ORIGIN = 'origin', + DESTINATION = 'destination', + TOKEN = 'token', +} + export function TransferTokenForm() { const multiProvider = useMultiProvider(); const warpCore = useWarpCore(); @@ -57,6 +65,8 @@ export function TransferTokenForm() { setIsReview(true); }; + if (!initialValues) return null; + return ( initialValues={initialValues} @@ -126,6 +136,11 @@ function ChainSelectSection({ isReview }: { isReview: boolean }) { return getNumRoutesWithSelectedChain(warpCore, values.destination, false); }, [values.destination, warpCore]); + useSyncQueryParam({ + [WARP_QUERY_PARAMS.ORIGIN]: values.origin || '', + [WARP_QUERY_PARAMS.DESTINATION]: values.destination || '', + }); + return (
{ const firstToken = warpCore.tokens[0]; const connectedToken = firstToken.connections?.[0]; + + if (!isReady) return null; + return { - origin: firstToken.chainName, - destination: connectedToken?.token?.chainName || '', - tokenIndex: getIndexForToken(warpCore, firstToken), + origin: defaultOriginQuery || firstToken.chainName, + destination: defaultDestinationQuery || connectedToken?.token?.chainName || '', + tokenIndex: defaultOriginQuery ? undefined : getIndexForToken(warpCore, firstToken), amount: '', recipient: '', }; - }, [warpCore]); + }, [warpCore, defaultOriginQuery, defaultDestinationQuery, isReady]); } const insufficientFundsErrMsg = /insufficient.[funds|lamports]/i; diff --git a/src/utils/queryParams.ts b/src/utils/queryParams.ts new file mode 100644 index 00000000..34d78c25 --- /dev/null +++ b/src/utils/queryParams.ts @@ -0,0 +1,51 @@ +import { useRouter } from 'next/router'; +import { ParsedUrlQuery } from 'querystring'; +import { useEffect } from 'react'; +import { logger } from './logger'; + +// To make Next's awkward query param typing more convenient +export function getQueryParamString(query: ParsedUrlQuery, key: string, defaultVal = '') { + if (!query) return defaultVal; + const val = query[key]; + if (val && typeof val === 'string') return val; + else return defaultVal; +} + +export function useMultipleQueryParams(keys: string[]) { + const router = useRouter(); + + return keys.map((key) => { + return getQueryParamString(router.query, key); + }); +} + +// Keep value in sync with query param in URL +export function useSyncQueryParam(params: Record) { + const router = useRouter(); + const { pathname, query } = router; + useEffect(() => { + let hasChanged = false; + const newQuery = new URLSearchParams( + Object.fromEntries( + Object.entries(query).filter((kv): kv is [string, string] => typeof kv[0] === 'string'), + ), + ); + Object.entries(params).forEach(([key, value]) => { + if (value && newQuery.get(key) !== value) { + newQuery.set(key, value); + hasChanged = true; + } else if (!value && newQuery.has(key)) { + newQuery.delete(key); + hasChanged = true; + } + }); + if (hasChanged) { + const path = `${pathname}?${newQuery.toString()}`; + router + .replace(path, undefined, { shallow: true }) + .catch((e) => logger.error('Error shallow updating URL', e)); + } + // Must exclude router for next.js shallow routing, otherwise links break: + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params]); +} From 06d71ab967ba6d5cf07120b4827bdf1418bbffcd Mon Sep 17 00:00:00 2001 From: Xaroz Date: Thu, 23 Jan 2025 16:36:54 -0400 Subject: [PATCH 2/8] feat: add token persistence and utils functions --- src/features/chains/utils.ts | 7 +++++ src/features/tokens/TokenSelectField.tsx | 24 +++++++++++--- src/features/transfer/TransferTokenForm.tsx | 35 ++++++++++++++++----- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/features/chains/utils.ts b/src/features/chains/utils.ts index be11c771..e38a7413 100644 --- a/src/features/chains/utils.ts +++ b/src/features/chains/utils.ts @@ -62,3 +62,10 @@ export function getNumRoutesWithSelectedChain( data, }; } + +/** + * Check if given chainName has valid chain metadata + */ +export function isChainValid(chainName: string, multiProvider: MultiProtocolProvider) { + return !!chainName && !!multiProvider.tryGetChainMetadata(chainName); +} diff --git a/src/features/tokens/TokenSelectField.tsx b/src/features/tokens/TokenSelectField.tsx index 17a4d690..2d520d27 100644 --- a/src/features/tokens/TokenSelectField.tsx +++ b/src/features/tokens/TokenSelectField.tsx @@ -1,6 +1,8 @@ import { IToken } from '@hyperlane-xyz/sdk'; +import { tryParseAmount } from '@hyperlane-xyz/utils'; import { ChevronIcon } from '@hyperlane-xyz/widgets'; import { useField, useFormikContext } from 'formik'; +import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { TokenIcon } from '../../components/icons/TokenIcon'; import { TransferFormValues } from '../transfer/types'; @@ -18,6 +20,7 @@ export function TokenSelectField({ name, disabled, setIsNft }: Props) { const [field, , helpers] = useField(name); const [isModalOpen, setIsModalOpen] = useState(false); const [isAutomaticSelection, setIsAutomaticSelection] = useState(false); + const { query } = useRouter(); const warpCore = useWarpCore(); @@ -26,23 +29,34 @@ export function TokenSelectField({ name, disabled, setIsNft }: Props) { const tokensWithRoute = warpCore.getTokensForRoute(origin, destination); let newFieldValue: number | undefined; let newIsAutomatic: boolean; + + // Use token from persistance + if (query.token && typeof query.token === 'string') { + // Check if tokenIndex exists + const tokenIndex = tryParseAmount(query.token)?.toNumber(); + if (tokenIndex !== undefined) { + const token = warpCore.tokens[tokenIndex]; + newFieldValue = token ? tokenIndex : undefined; + } + } // No tokens available for this route - if (tokensWithRoute.length === 0) { + else 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; } + + if (tokensWithRoute.length <= 1) newIsAutomatic = true; + else newIsAutomatic = false; + helpers.setValue(newFieldValue); setIsAutomaticSelection(newIsAutomatic); - }, [warpCore, origin, destination, helpers]); + }, [warpCore, origin, destination, helpers, query.token]); const onSelectToken = (newToken: IToken) => { // Set the token address value in formik state diff --git a/src/features/transfer/TransferTokenForm.tsx b/src/features/transfer/TransferTokenForm.tsx index fbf65436..e37ea427 100644 --- a/src/features/transfer/TransferTokenForm.tsx +++ b/src/features/transfer/TransferTokenForm.tsx @@ -1,5 +1,11 @@ import { TokenAmount, WarpCore } from '@hyperlane-xyz/sdk'; -import { ProtocolType, errorToString, isNullish, toWei } from '@hyperlane-xyz/utils'; +import { + ProtocolType, + errorToString, + isNullish, + toWei, + tryParseAmount, +} from '@hyperlane-xyz/utils'; import { AccountInfo, ChevronIcon, @@ -26,7 +32,7 @@ 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, isChainValid } from '../chains/utils'; import { useIsAccountSanctioned } from '../sanctions/hooks/useIsAccountSanctioned'; import { useStore } from '../store'; import { SelectOrInputTokenIds } from '../tokens/SelectOrInputTokenIds'; @@ -139,6 +145,7 @@ function ChainSelectSection({ isReview }: { isReview: boolean }) { useSyncQueryParam({ [WARP_QUERY_PARAMS.ORIGIN]: values.origin || '', [WARP_QUERY_PARAMS.DESTINATION]: values.destination || '', + [WARP_QUERY_PARAMS.TOKEN]: values.tokenIndex !== undefined ? String(values.tokenIndex) : '', }); return ( @@ -465,10 +472,13 @@ function WarningBanners() { function useFormInitialValues(): TransferFormValues | null { const warpCore = useWarpCore(); const { isReady } = useRouter(); - const [defaultOriginQuery, defaultDestinationQuery] = useMultipleQueryParams([ + const [defaultOriginQuery, defaultDestinationQuery, defaultTokenQuery] = useMultipleQueryParams([ WARP_QUERY_PARAMS.ORIGIN, WARP_QUERY_PARAMS.DESTINATION, + WARP_QUERY_PARAMS.TOKEN, ]); + const isOriginQueryValid = isChainValid(defaultOriginQuery, warpCore.multiProvider); + const isDestinationQueryValid = isChainValid(defaultDestinationQuery, warpCore.multiProvider); return useMemo(() => { const firstToken = warpCore.tokens[0]; @@ -477,13 +487,24 @@ function useFormInitialValues(): TransferFormValues | null { if (!isReady) return null; return { - origin: defaultOriginQuery || firstToken.chainName, - destination: defaultDestinationQuery || connectedToken?.token?.chainName || '', - tokenIndex: defaultOriginQuery ? undefined : getIndexForToken(warpCore, firstToken), + origin: isOriginQueryValid ? defaultOriginQuery : firstToken.chainName, + destination: isDestinationQueryValid + ? defaultDestinationQuery + : connectedToken?.token?.chainName || '', + tokenIndex: + tryParseAmount(defaultTokenQuery)?.toNumber() || getIndexForToken(warpCore, firstToken), amount: '', recipient: '', }; - }, [warpCore, defaultOriginQuery, defaultDestinationQuery, isReady]); + }, [ + warpCore, + isReady, + defaultOriginQuery, + defaultDestinationQuery, + defaultTokenQuery, + isOriginQueryValid, + isDestinationQueryValid, + ]); } const insufficientFundsErrMsg = /insufficient.[funds|lamports]/i; From 6eb7689ea11bba97ab2c3cc62388740636f61376 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Fri, 24 Jan 2025 15:39:02 -0400 Subject: [PATCH 3/8] feat: update status machine to use browser defaults --- src/features/chains/ChainSelectField.tsx | 5 +- src/features/chains/utils.ts | 12 +- src/features/tokens/TokenSelectField.tsx | 31 +---- src/features/tokens/hooks.ts | 44 +++++++ src/features/transfer/TransferTokenForm.tsx | 120 ++++++++++++-------- src/utils/queryParams.ts | 64 +++-------- 6 files changed, 146 insertions(+), 130 deletions(-) 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 e38a7413..f84e994c 100644 --- a/src/features/chains/utils.ts +++ b/src/features/chains/utils.ts @@ -66,6 +66,16 @@ export function getNumRoutesWithSelectedChain( /** * Check if given chainName has valid chain metadata */ -export function isChainValid(chainName: string, multiProvider: MultiProtocolProvider) { +export function isChainValid(chainName: string | null, multiProvider: MultiProtocolProvider) { return !!chainName && !!multiProvider.tryGetChainMetadata(chainName); } + +/** + * Check if given chainName has valid chain metadata and return chainName if chain is valid + */ +export function getValidChain( + chainName: string | null, + multiProvider: MultiProtocolProvider, +): string | undefined { + return chainName && multiProvider.tryGetChainMetadata(chainName) ? chainName : undefined; +} diff --git a/src/features/tokens/TokenSelectField.tsx b/src/features/tokens/TokenSelectField.tsx index 2d520d27..62c42b01 100644 --- a/src/features/tokens/TokenSelectField.tsx +++ b/src/features/tokens/TokenSelectField.tsx @@ -1,10 +1,10 @@ import { IToken } from '@hyperlane-xyz/sdk'; -import { tryParseAmount } from '@hyperlane-xyz/utils'; import { ChevronIcon } from '@hyperlane-xyz/widgets'; import { useField, useFormikContext } from 'formik'; -import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { TokenIcon } from '../../components/icons/TokenIcon'; +import { updateQueryParam } from '../../utils/queryParams'; +import { WARP_QUERY_PARAMS } from '../transfer/TransferTokenForm'; import { TransferFormValues } from '../transfer/types'; import { TokenListModal } from './TokenListModal'; import { getIndexForToken, getTokenByIndex, useWarpCore } from './hooks'; @@ -20,47 +20,24 @@ export function TokenSelectField({ name, disabled, setIsNft }: Props) { const [field, , helpers] = useField(name); const [isModalOpen, setIsModalOpen] = useState(false); const [isAutomaticSelection, setIsAutomaticSelection] = useState(false); - const { query } = useRouter(); const warpCore = useWarpCore(); const { origin, destination } = values; useEffect(() => { const tokensWithRoute = warpCore.getTokensForRoute(origin, destination); - let newFieldValue: number | undefined; let newIsAutomatic: boolean; - // Use token from persistance - if (query.token && typeof query.token === 'string') { - // Check if tokenIndex exists - const tokenIndex = tryParseAmount(query.token)?.toNumber(); - if (tokenIndex !== undefined) { - const token = warpCore.tokens[tokenIndex]; - newFieldValue = token ? tokenIndex : undefined; - } - } - // No tokens available for this route - else if (tokensWithRoute.length === 0) { - newFieldValue = undefined; - } - // Exactly one found - else if (tokensWithRoute.length === 1) { - newFieldValue = getIndexForToken(warpCore, tokensWithRoute[0]); - // Multiple possibilities - } else { - newFieldValue = undefined; - } - if (tokensWithRoute.length <= 1) newIsAutomatic = true; else newIsAutomatic = false; - helpers.setValue(newFieldValue); setIsAutomaticSelection(newIsAutomatic); - }, [warpCore, origin, destination, helpers, query.token]); + }, [warpCore, origin, destination, helpers]); const onSelectToken = (newToken: IToken) => { // Set the token address value in formik state helpers.setValue(getIndexForToken(warpCore, newToken)); + updateQueryParam(WARP_QUERY_PARAMS.TOKEN, 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 e37ea427..440c4a0c 100644 --- a/src/features/transfer/TransferTokenForm.tsx +++ b/src/features/transfer/TransferTokenForm.tsx @@ -1,11 +1,5 @@ import { TokenAmount, WarpCore } from '@hyperlane-xyz/sdk'; -import { - ProtocolType, - errorToString, - isNullish, - toWei, - tryParseAmount, -} from '@hyperlane-xyz/utils'; +import { ProtocolType, errorToString, isNullish, toWei } from '@hyperlane-xyz/utils'; import { AccountInfo, ChevronIcon, @@ -18,7 +12,6 @@ import { } from '@hyperlane-xyz/widgets'; import BigNumber from 'bignumber.js'; import { Form, Formik, useFormikContext } from 'formik'; -import { useRouter } from 'next/router'; import { useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import { ConnectAwareSubmitButton } from '../../components/buttons/ConnectAwareSubmitButton'; @@ -27,26 +20,31 @@ import { TextField } from '../../components/input/TextField'; import { config } from '../../consts/config'; import { Color } from '../../styles/Color'; import { logger } from '../../utils/logger'; -import { useMultipleQueryParams, useSyncQueryParam } from '../../utils/queryParams'; +import { 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, isChainValid } from '../chains/utils'; +import { getNumRoutesWithSelectedChain, getValidChain } from '../chains/utils'; import { useIsAccountSanctioned } from '../sanctions/hooks/useIsAccountSanctioned'; import { useStore } from '../store'; import { SelectOrInputTokenIds } from '../tokens/SelectOrInputTokenIds'; import { TokenSelectField } from '../tokens/TokenSelectField'; import { useIsApproveRequired } from '../tokens/approval'; import { useDestinationBalance, useOriginBalance } from '../tokens/balances'; -import { getIndexForToken, getTokenByIndex, useWarpCore } from '../tokens/hooks'; +import { + getIndexForToken, + getInitialTokenIndex, + getTokenByIndex, + useWarpCore, +} from '../tokens/hooks'; import { useFetchMaxAmount } from './maxAmount'; import { TransferFormValues } from './types'; import { useRecipientBalanceWatcher } from './useBalanceWatcher'; import { useFeeQuotes } from './useFeeQuotes'; import { useTokenTransfer } from './useTokenTransfer'; -enum WARP_QUERY_PARAMS { +export enum WARP_QUERY_PARAMS { ORIGIN = 'origin', DESTINATION = 'destination', TOKEN = 'token', @@ -71,8 +69,6 @@ export function TransferTokenForm() { setIsReview(true); }; - if (!initialValues) return null; - return ( initialValues={initialValues} @@ -102,7 +98,13 @@ export function TransferTokenForm() { ); } -function SwapChainsButton({ disabled }: { disabled?: boolean }) { +function SwapChainsButton({ + disabled, + handleSwapChain, +}: { + disabled?: boolean; + handleSwapChain(origin: string, destination: string): void; +}) { const { values, setFieldValue } = useFormikContext(); const { origin, destination } = values; @@ -111,8 +113,8 @@ function SwapChainsButton({ disabled }: { disabled?: boolean }) { setFieldValue('origin', destination); setFieldValue('destination', origin); // Reset other fields on chain change - setFieldValue('tokenIndex', undefined); setFieldValue('recipient', ''); + handleSwapChain(destination, origin); }; return ( @@ -132,7 +134,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); @@ -142,11 +144,33 @@ function ChainSelectSection({ isReview }: { isReview: boolean }) { return getNumRoutesWithSelectedChain(warpCore, values.destination, false); }, [values.destination, warpCore]); - useSyncQueryParam({ - [WARP_QUERY_PARAMS.ORIGIN]: values.origin || '', - [WARP_QUERY_PARAMS.DESTINATION]: values.destination || '', - [WARP_QUERY_PARAMS.TOKEN]: values.tokenIndex !== undefined ? String(values.tokenIndex) : '', - }); + const handleGetTokensRoute = (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 === 'origin') handleGetTokensRoute(chainName, values.destination); + else handleGetTokensRoute(values.origin, chainName); + updateQueryParam(fieldName, chainName); + }; + + const handleSwapChain = (origin: string, destination: string) => { + updateQueryParam(WARP_QUERY_PARAMS.ORIGIN, origin); + updateQueryParam(WARP_QUERY_PARAMS.DESTINATION, destination); + handleGetTokensRoute(origin, destination); + }; return (
@@ -155,15 +179,17 @@ function ChainSelectSection({ isReview }: { isReview: boolean }) { label="From" disabled={isReview} customListItemField={destinationRouteCounts} + onChange={handleChange} />
- +
); @@ -469,42 +495,38 @@ function WarningBanners() { ); } -function useFormInitialValues(): TransferFormValues | null { +function useFormInitialValues(): TransferFormValues { const warpCore = useWarpCore(); - const { isReady } = useRouter(); - const [defaultOriginQuery, defaultDestinationQuery, defaultTokenQuery] = useMultipleQueryParams([ - WARP_QUERY_PARAMS.ORIGIN, - WARP_QUERY_PARAMS.DESTINATION, - WARP_QUERY_PARAMS.TOKEN, - ]); - const isOriginQueryValid = isChainValid(defaultOriginQuery, warpCore.multiProvider); - const isDestinationQueryValid = isChainValid(defaultDestinationQuery, warpCore.multiProvider); + const parameters = new URLSearchParams(window.location.search); + + const originQuery = getValidChain( + parameters.get(WARP_QUERY_PARAMS.ORIGIN), + warpCore.multiProvider, + ); + const destinationQuery = getValidChain( + parameters.get(WARP_QUERY_PARAMS.DESTINATION), + warpCore.multiProvider, + ); + + const tokenIndex = getInitialTokenIndex( + warpCore, + parameters.get(WARP_QUERY_PARAMS.TOKEN), + originQuery, + destinationQuery, + ); return useMemo(() => { const firstToken = warpCore.tokens[0]; const connectedToken = firstToken.connections?.[0]; - if (!isReady) return null; - return { - origin: isOriginQueryValid ? defaultOriginQuery : firstToken.chainName, - destination: isDestinationQueryValid - ? defaultDestinationQuery - : connectedToken?.token?.chainName || '', - tokenIndex: - tryParseAmount(defaultTokenQuery)?.toNumber() || getIndexForToken(warpCore, firstToken), + origin: originQuery ? originQuery : firstToken.chainName, + destination: destinationQuery ? destinationQuery : connectedToken?.token?.chainName || '', + tokenIndex: tokenIndex, amount: '', recipient: '', }; - }, [ - warpCore, - isReady, - defaultOriginQuery, - defaultDestinationQuery, - defaultTokenQuery, - isOriginQueryValid, - isDestinationQueryValid, - ]); + }, [warpCore, destinationQuery, originQuery, tokenIndex]); } const insufficientFundsErrMsg = /insufficient.[funds|lamports]/i; diff --git a/src/utils/queryParams.ts b/src/utils/queryParams.ts index 34d78c25..cddbe09f 100644 --- a/src/utils/queryParams.ts +++ b/src/utils/queryParams.ts @@ -1,51 +1,15 @@ -import { useRouter } from 'next/router'; -import { ParsedUrlQuery } from 'querystring'; -import { useEffect } from 'react'; -import { logger } from './logger'; - -// To make Next's awkward query param typing more convenient -export function getQueryParamString(query: ParsedUrlQuery, key: string, defaultVal = '') { - if (!query) return defaultVal; - const val = query[key]; - if (val && typeof val === 'string') return val; - else return defaultVal; -} - -export function useMultipleQueryParams(keys: string[]) { - const router = useRouter(); - - return keys.map((key) => { - return getQueryParamString(router.query, key); - }); -} - -// Keep value in sync with query param in URL -export function useSyncQueryParam(params: Record) { - const router = useRouter(); - const { pathname, query } = router; - useEffect(() => { - let hasChanged = false; - const newQuery = new URLSearchParams( - Object.fromEntries( - Object.entries(query).filter((kv): kv is [string, string] => typeof kv[0] === 'string'), - ), - ); - Object.entries(params).forEach(([key, value]) => { - if (value && newQuery.get(key) !== value) { - newQuery.set(key, value); - hasChanged = true; - } else if (!value && newQuery.has(key)) { - newQuery.delete(key); - hasChanged = true; - } - }); - if (hasChanged) { - const path = `${pathname}?${newQuery.toString()}`; - router - .replace(path, undefined, { shallow: true }) - .catch((e) => logger.error('Error shallow updating URL', e)); - } - // Must exclude router for next.js shallow routing, otherwise links break: - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [params]); +export function updateQueryParam(key: string, value?: string | number) { + const params = new URLSearchParams(window.location.search); // 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); } From 9d5c0797727a9542ec2e34d08914258b2f381b7c Mon Sep 17 00:00:00 2001 From: Xaroz Date: Fri, 24 Jan 2025 15:41:20 -0400 Subject: [PATCH 4/8] chore: clean up --- src/features/tokens/TokenSelectField.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/features/tokens/TokenSelectField.tsx b/src/features/tokens/TokenSelectField.tsx index 62c42b01..621a6fe2 100644 --- a/src/features/tokens/TokenSelectField.tsx +++ b/src/features/tokens/TokenSelectField.tsx @@ -26,12 +26,7 @@ export function TokenSelectField({ name, disabled, setIsNft }: Props) { const { origin, destination } = values; useEffect(() => { const tokensWithRoute = warpCore.getTokensForRoute(origin, destination); - let newIsAutomatic: boolean; - - if (tokensWithRoute.length <= 1) newIsAutomatic = true; - else newIsAutomatic = false; - - setIsAutomaticSelection(newIsAutomatic); + setIsAutomaticSelection(tokensWithRoute.length <= 1); }, [warpCore, origin, destination, helpers]); const onSelectToken = (newToken: IToken) => { From dd13742d2c0a5ff2edd5aa4515e30a76b75150b7 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Fri, 24 Jan 2025 15:47:34 -0400 Subject: [PATCH 5/8] chore: remove unused util function --- src/features/chains/utils.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/features/chains/utils.ts b/src/features/chains/utils.ts index f84e994c..d964318d 100644 --- a/src/features/chains/utils.ts +++ b/src/features/chains/utils.ts @@ -63,13 +63,6 @@ export function getNumRoutesWithSelectedChain( }; } -/** - * Check if given chainName has valid chain metadata - */ -export function isChainValid(chainName: string | null, multiProvider: MultiProtocolProvider) { - return !!chainName && !!multiProvider.tryGetChainMetadata(chainName); -} - /** * Check if given chainName has valid chain metadata and return chainName if chain is valid */ From 8818f758e05365a95f0058d1ab1e2ddd7c8d46b9 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Fri, 24 Jan 2025 16:35:06 -0400 Subject: [PATCH 6/8] fix: cycle dependency --- src/consts/app.ts | 6 ++++++ src/features/tokens/TokenSelectField.tsx | 2 +- src/features/transfer/TransferTokenForm.tsx | 7 +------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/consts/app.ts b/src/consts/app.ts index 2dc51e86..bc0594e3 100644 --- a/src/consts/app.ts +++ b/src/consts/app.ts @@ -13,3 +13,9 @@ export const APP_URL = 'hyperlane-warp-template.vercel.app'; export const BRAND_COLOR = Color.primary['500']; export const BACKGROUND_COLOR = Color.primary['500']; export const BACKGROUND_IMAGE = 'url(/backgrounds/main.svg)'; + +export enum WARP_QUERY_PARAMS { + ORIGIN = 'origin', + DESTINATION = 'destination', + TOKEN = 'token', +} diff --git a/src/features/tokens/TokenSelectField.tsx b/src/features/tokens/TokenSelectField.tsx index 621a6fe2..3c8c2669 100644 --- a/src/features/tokens/TokenSelectField.tsx +++ b/src/features/tokens/TokenSelectField.tsx @@ -3,8 +3,8 @@ import { ChevronIcon } from '@hyperlane-xyz/widgets'; import { useField, useFormikContext } from 'formik'; import { useEffect, useState } from 'react'; import { TokenIcon } from '../../components/icons/TokenIcon'; +import { WARP_QUERY_PARAMS } from '../../consts/app'; import { updateQueryParam } from '../../utils/queryParams'; -import { WARP_QUERY_PARAMS } from '../transfer/TransferTokenForm'; import { TransferFormValues } from '../transfer/types'; import { TokenListModal } from './TokenListModal'; import { getIndexForToken, getTokenByIndex, useWarpCore } from './hooks'; diff --git a/src/features/transfer/TransferTokenForm.tsx b/src/features/transfer/TransferTokenForm.tsx index 440c4a0c..82d00961 100644 --- a/src/features/transfer/TransferTokenForm.tsx +++ b/src/features/transfer/TransferTokenForm.tsx @@ -17,6 +17,7 @@ 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/app'; import { config } from '../../consts/config'; import { Color } from '../../styles/Color'; import { logger } from '../../utils/logger'; @@ -44,12 +45,6 @@ import { useRecipientBalanceWatcher } from './useBalanceWatcher'; import { useFeeQuotes } from './useFeeQuotes'; import { useTokenTransfer } from './useTokenTransfer'; -export enum WARP_QUERY_PARAMS { - ORIGIN = 'origin', - DESTINATION = 'destination', - TOKEN = 'token', -} - export function TransferTokenForm() { const multiProvider = useMultiProvider(); const warpCore = useWarpCore(); From e2b9a5502118f9d70bfc898b37f8418c364062e6 Mon Sep 17 00:00:00 2001 From: Xaroz Date: Sun, 26 Jan 2025 15:33:27 -0400 Subject: [PATCH 7/8] chore: PR recommendations --- src/consts/app.ts | 6 --- src/consts/core.ts | 5 +++ src/features/tokens/TokenSelectField.tsx | 8 ++-- src/features/transfer/TransferTokenForm.tsx | 46 ++++++++++++--------- src/utils/queryParams.ts | 6 ++- 5 files changed, 41 insertions(+), 30 deletions(-) create mode 100644 src/consts/core.ts diff --git a/src/consts/app.ts b/src/consts/app.ts index bc0594e3..2dc51e86 100644 --- a/src/consts/app.ts +++ b/src/consts/app.ts @@ -13,9 +13,3 @@ export const APP_URL = 'hyperlane-warp-template.vercel.app'; export const BRAND_COLOR = Color.primary['500']; export const BACKGROUND_COLOR = Color.primary['500']; export const BACKGROUND_IMAGE = 'url(/backgrounds/main.svg)'; - -export enum WARP_QUERY_PARAMS { - ORIGIN = 'origin', - DESTINATION = 'destination', - TOKEN = 'token', -} diff --git a/src/consts/core.ts b/src/consts/core.ts new file mode 100644 index 00000000..5b5970b5 --- /dev/null +++ b/src/consts/core.ts @@ -0,0 +1,5 @@ +export enum WARP_QUERY_PARAMS { + ORIGIN = 'origin', + DESTINATION = 'destination', + TOKEN = 'token', +} diff --git a/src/features/tokens/TokenSelectField.tsx b/src/features/tokens/TokenSelectField.tsx index 3c8c2669..fb8bd531 100644 --- a/src/features/tokens/TokenSelectField.tsx +++ b/src/features/tokens/TokenSelectField.tsx @@ -3,8 +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 { WARP_QUERY_PARAMS } from '../../consts/app'; -import { updateQueryParam } from '../../utils/queryParams'; + import { TransferFormValues } from '../transfer/types'; import { TokenListModal } from './TokenListModal'; import { getIndexForToken, getTokenByIndex, useWarpCore } from './hooks'; @@ -13,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); @@ -32,7 +32,7 @@ export function TokenSelectField({ name, disabled, setIsNft }: Props) { const onSelectToken = (newToken: IToken) => { // Set the token address value in formik state helpers.setValue(getIndexForToken(warpCore, newToken)); - updateQueryParam(WARP_QUERY_PARAMS.TOKEN, newToken.addressOrDenom); + onChangeToken(newToken.addressOrDenom); // Update nft state in parent setIsNft(newToken.isNft()); }; diff --git a/src/features/transfer/TransferTokenForm.tsx b/src/features/transfer/TransferTokenForm.tsx index 82d00961..686db55e 100644 --- a/src/features/transfer/TransferTokenForm.tsx +++ b/src/features/transfer/TransferTokenForm.tsx @@ -17,11 +17,11 @@ 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/app'; import { config } from '../../consts/config'; +import { WARP_QUERY_PARAMS } from '../../consts/core'; import { Color } from '../../styles/Color'; import { logger } from '../../utils/logger'; -import { updateQueryParam } from '../../utils/queryParams'; +import { getQueryParams, updateQueryParam } from '../../utils/queryParams'; import { ChainConnectionWarning } from '../chains/ChainConnectionWarning'; import { ChainSelectField } from '../chains/ChainSelectField'; import { ChainWalletWarning } from '../chains/ChainWalletWarning'; @@ -95,10 +95,10 @@ export function TransferTokenForm() { function SwapChainsButton({ disabled, - handleSwapChain, + onSwapChain, }: { disabled?: boolean; - handleSwapChain(origin: string, destination: string): void; + onSwapChain: (origin: string, destination: string) => void; }) { const { values, setFieldValue } = useFormikContext(); const { origin, destination } = values; @@ -109,7 +109,7 @@ function SwapChainsButton({ setFieldValue('destination', origin); // Reset other fields on chain change setFieldValue('recipient', ''); - handleSwapChain(destination, origin); + onSwapChain(destination, origin); }; return ( @@ -139,7 +139,7 @@ function ChainSelectSection({ isReview }: { isReview: boolean }) { return getNumRoutesWithSelectedChain(warpCore, values.destination, false); }, [values.destination, warpCore]); - const handleGetTokensRoute = (origin: string, destination: string) => { + const setTokenOnChainChange = (origin: string, destination: string) => { const tokensWithRoute = warpCore.getTokensForRoute(origin, destination); let newFieldValue: number | undefined; if (tokensWithRoute.length === 1) { @@ -156,15 +156,17 @@ function ChainSelectSection({ isReview }: { isReview: boolean }) { }; const handleChange = (chainName: string, fieldName: string) => { - if (fieldName === 'origin') handleGetTokensRoute(chainName, values.destination); - else handleGetTokensRoute(values.origin, chainName); + 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 handleSwapChain = (origin: string, destination: string) => { + const onSwapChain = (origin: string, destination: string) => { updateQueryParam(WARP_QUERY_PARAMS.ORIGIN, origin); updateQueryParam(WARP_QUERY_PARAMS.DESTINATION, destination); - handleGetTokensRoute(origin, destination); + setTokenOnChainChange(origin, destination); }; return ( @@ -177,7 +179,7 @@ function ChainSelectSection({ isReview }: { isReview: boolean }) { onChange={handleChange} />
- +
void; isReview: boolean; }) { + const onChangeToken = (addressOrDenom: string) => { + updateQueryParam(WARP_QUERY_PARAMS.TOKEN, addressOrDenom); + }; + return (
- +
); } @@ -492,20 +503,17 @@ function WarningBanners() { function useFormInitialValues(): TransferFormValues { const warpCore = useWarpCore(); - const parameters = new URLSearchParams(window.location.search); + const params = getQueryParams(); - const originQuery = getValidChain( - parameters.get(WARP_QUERY_PARAMS.ORIGIN), - warpCore.multiProvider, - ); + const originQuery = getValidChain(params.get(WARP_QUERY_PARAMS.ORIGIN), warpCore.multiProvider); const destinationQuery = getValidChain( - parameters.get(WARP_QUERY_PARAMS.DESTINATION), + params.get(WARP_QUERY_PARAMS.DESTINATION), warpCore.multiProvider, ); const tokenIndex = getInitialTokenIndex( warpCore, - parameters.get(WARP_QUERY_PARAMS.TOKEN), + params.get(WARP_QUERY_PARAMS.TOKEN), originQuery, destinationQuery, ); diff --git a/src/utils/queryParams.ts b/src/utils/queryParams.ts index cddbe09f..f8ec12c0 100644 --- a/src/utils/queryParams.ts +++ b/src/utils/queryParams.ts @@ -1,5 +1,9 @@ +export function getQueryParams() { + return new URLSearchParams(window.location.search); +} + export function updateQueryParam(key: string, value?: string | number) { - const params = new URLSearchParams(window.location.search); // Get current query parameters + const params = getQueryParams(); // Get current query parameters if (value === undefined || value === null) { // Remove the parameter if the value is undefined or null From 34226749d4863b8b95147f0f739709ed7b27376b Mon Sep 17 00:00:00 2001 From: Xaroz Date: Mon, 27 Jan 2025 17:00:43 -0600 Subject: [PATCH 8/8] chore: update file and function names --- src/consts/{core.ts => args.ts} | 0 src/features/chains/utils.ts | 6 +++--- src/features/transfer/TransferTokenForm.tsx | 11 +++++++---- 3 files changed, 10 insertions(+), 7 deletions(-) rename src/consts/{core.ts => args.ts} (100%) diff --git a/src/consts/core.ts b/src/consts/args.ts similarity index 100% rename from src/consts/core.ts rename to src/consts/args.ts diff --git a/src/features/chains/utils.ts b/src/features/chains/utils.ts index d964318d..9e59ecca 100644 --- a/src/features/chains/utils.ts +++ b/src/features/chains/utils.ts @@ -64,11 +64,11 @@ export function getNumRoutesWithSelectedChain( } /** - * Check if given chainName has valid chain metadata and return chainName if chain is valid + * Return given chainName if it is valid, otherwise return undefined */ -export function getValidChain( +export function tryGetValidChainName( chainName: string | null, multiProvider: MultiProtocolProvider, ): string | undefined { - return chainName && multiProvider.tryGetChainMetadata(chainName) ? chainName : undefined; + return chainName && multiProvider.tryGetChainName(chainName) ? chainName : undefined; } diff --git a/src/features/transfer/TransferTokenForm.tsx b/src/features/transfer/TransferTokenForm.tsx index 686db55e..d4b72fd4 100644 --- a/src/features/transfer/TransferTokenForm.tsx +++ b/src/features/transfer/TransferTokenForm.tsx @@ -17,8 +17,8 @@ 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 { WARP_QUERY_PARAMS } from '../../consts/core'; import { Color } from '../../styles/Color'; import { logger } from '../../utils/logger'; import { getQueryParams, updateQueryParam } from '../../utils/queryParams'; @@ -26,7 +26,7 @@ import { ChainConnectionWarning } from '../chains/ChainConnectionWarning'; import { ChainSelectField } from '../chains/ChainSelectField'; import { ChainWalletWarning } from '../chains/ChainWalletWarning'; import { useChainDisplayName, useMultiProvider } from '../chains/hooks'; -import { getNumRoutesWithSelectedChain, getValidChain } from '../chains/utils'; +import { getNumRoutesWithSelectedChain, tryGetValidChainName } from '../chains/utils'; import { useIsAccountSanctioned } from '../sanctions/hooks/useIsAccountSanctioned'; import { useStore } from '../store'; import { SelectOrInputTokenIds } from '../tokens/SelectOrInputTokenIds'; @@ -505,8 +505,11 @@ function useFormInitialValues(): TransferFormValues { const warpCore = useWarpCore(); const params = getQueryParams(); - const originQuery = getValidChain(params.get(WARP_QUERY_PARAMS.ORIGIN), warpCore.multiProvider); - const destinationQuery = getValidChain( + const originQuery = tryGetValidChainName( + params.get(WARP_QUERY_PARAMS.ORIGIN), + warpCore.multiProvider, + ); + const destinationQuery = tryGetValidChainName( params.get(WARP_QUERY_PARAMS.DESTINATION), warpCore.multiProvider, );