Finish support for dynamic chain lists except for ChainIcon

pull/13/head
J M Rossy 2 years ago
parent 68f4406f71
commit 3df430a210
  1. 8
      src/features/chains/ChainSelectField.tsx
  2. 83
      src/features/chains/ChainSelectModal.tsx
  3. 41
      src/features/chains/metadata.ts
  4. 19
      src/features/providers.ts
  5. 23
      src/features/tokens/routes.ts
  6. 57
      src/features/transfer/TransferTokenForm.tsx
  7. 5
      src/pages/_app.tsx

@ -5,17 +5,18 @@ import { useState } from 'react';
import { ChainIcon } from '../../components/icons/ChainIcon'; import { ChainIcon } from '../../components/icons/ChainIcon';
import ChevronIcon from '../../images/icons/chevron-down.svg'; import ChevronIcon from '../../images/icons/chevron-down.svg';
import { ChainSelectModal } from './ChainSelectModal'; import { ChainSelectListModal } from './ChainSelectModal';
import { getChainDisplayName } from './metadata'; import { getChainDisplayName } from './metadata';
type Props = { type Props = {
name: string; name: string;
label: string; label: string;
chainIds: number[];
onChange?: (chainId: number) => void; onChange?: (chainId: number) => void;
disabled?: boolean; disabled?: boolean;
}; };
export function ChainSelectField({ name, label, onChange, disabled }: Props) { export function ChainSelectField({ name, label, chainIds, onChange, disabled }: Props) {
const [field, , helpers] = useField<number>(name); const [field, , helpers] = useField<number>(name);
const handleChange = (newChainId: number) => { const handleChange = (newChainId: number) => {
@ -51,9 +52,10 @@ export function ChainSelectField({ name, label, onChange, disabled }: Props) {
</div> </div>
<Image src={ChevronIcon} width={12} height={8} alt="" /> <Image src={ChevronIcon} width={12} height={8} alt="" />
</button> </button>
<ChainSelectModal <ChainSelectListModal
isOpen={isModalOpen} isOpen={isModalOpen}
close={() => setIsModalOpen(false)} close={() => setIsModalOpen(false)}
chainIds={chainIds}
onSelect={handleChange} onSelect={handleChange}
/> />
</div> </div>

@ -1,17 +1,17 @@
import { mainnetChainsMetadata, testnetChainsMetadata } from '@hyperlane-xyz/sdk';
import { ChainIcon } from '../../components/icons/ChainIcon'; import { ChainIcon } from '../../components/icons/ChainIcon';
import { Modal } from '../../components/layout/Modal'; import { Modal } from '../../components/layout/Modal';
import { getChainDisplayName } from './metadata'; import { getChainDisplayName, getChainMetadata } from './metadata';
export function ChainSelectModal({ export function ChainSelectListModal({
isOpen, isOpen,
close, close,
chainIds,
onSelect, onSelect,
}: { }: {
isOpen: boolean; isOpen: boolean;
close: () => void; close: () => void;
chainIds: number[];
onSelect: (chainId: number) => void; onSelect: (chainId: number) => void;
}) { }) {
const onSelectChain = (chainId: number) => { const onSelectChain = (chainId: number) => {
@ -21,12 +21,12 @@ export function ChainSelectModal({
}; };
}; };
const chainMetadata = chainIds.map((c) => getChainMetadata(c));
return ( return (
<Modal isOpen={isOpen} title="Send Tokens" close={close}> <Modal isOpen={isOpen} title="Select Chain" close={close}>
<div className="mt-1 flex justify-between"> <div className="mt-2 flex flex-col space-y-1">
<div className="flex flex-col space-y-0.5 relative -left-2"> {chainMetadata.map((c) => (
<h4 className="py-1.5 px-2 text-sm text-gray-500 uppercase">Mainnet</h4>
{mainnetChainsMetadata.map((c) => (
<button <button
key={c.name} key={c.name}
className="py-1.5 px-2 text-sm flex items-center rounded hover:bg-gray-100 active:bg-gray-200 transition-all duration-200" className="py-1.5 px-2 text-sm flex items-center rounded hover:bg-gray-100 active:bg-gray-200 transition-all duration-200"
@ -37,20 +37,57 @@ export function ChainSelectModal({
</button> </button>
))} ))}
</div> </div>
<div className="flex flex-col space-y-0.5 pr-3">
<h4 className="py-1.5 px-2 text-sm text-gray-500 uppercase">Testnet</h4>
{testnetChainsMetadata.map((c) => (
<button
key={c.name}
className="py-1.5 px-2 text-sm flex items-center rounded hover:bg-gray-100 active:bg-gray-200 transition-all duration-200"
onClick={onSelectChain(c.id)}
>
<ChainIcon chainId={c.id} size={16} background={false} />
<span className="ml-2">{getChainDisplayName(c.id, true)}</span>
</button>
))}
</div>
</div>
</Modal> </Modal>
); );
} }
// TODO update to support dynamic chain lists
// export function ChainSelectGridModal({
// isOpen,
// close,
// onSelect,
// }: {
// isOpen: boolean;
// close: () => void;
// onSelect: (chainId: number) => void;
// }) {
// const onSelectChain = (chainId: number) => {
// return () => {
// onSelect(chainId);
// close();
// };
// };
// return (
// <Modal isOpen={isOpen} title="elect Chain" close={close}>
// <div className="mt-1 flex justify-between">
// <div className="flex flex-col space-y-0.5 relative -left-2">
// <h4 className="py-1.5 px-2 text-sm text-gray-500 uppercase">Mainnet</h4>
// {mainnetChainsMetadata.map((c) => (
// <button
// key={c.name}
// className="py-1.5 px-2 text-sm flex items-center rounded hover:bg-gray-100 active:bg-gray-200 transition-all duration-200"
// onClick={onSelectChain(c.id)}
// >
// <ChainIcon chainId={c.id} size={16} background={false} />
// <span className="ml-2">{getChainDisplayName(c.id, true)}</span>
// </button>
// ))}
// </div>
// <div className="flex flex-col space-y-0.5 pr-3">
// <h4 className="py-1.5 px-2 text-sm text-gray-500 uppercase">Testnet</h4>
// {testnetChainsMetadata.map((c) => (
// <button
// key={c.name}
// className="py-1.5 px-2 text-sm flex items-center rounded hover:bg-gray-100 active:bg-gray-200 transition-all duration-200"
// onClick={onSelectChain(c.id)}
// >
// <ChainIcon chainId={c.id} size={16} background={false} />
// <span className="ml-2">{getChainDisplayName(c.id, true)}</span>
// </button>
// ))}
// </div>
// </div>
// </Modal>
// );
// }

@ -1,4 +1,6 @@
import { ChainMetadata, chainIdToMetadata } from '@hyperlane-xyz/sdk'; import type { Chain as WagmiChain } from '@wagmi/chains';
import { ChainMetadata, chainIdToMetadata, objMap, wagmiChainMetadata } from '@hyperlane-xyz/sdk';
import { chainIdToCustomConfig } from '../../consts/chains'; import { chainIdToCustomConfig } from '../../consts/chains';
@ -8,14 +10,47 @@ export function getChainMetadata(chainId: number): ChainMetadata {
else throw new Error(`No metadata found for chain ${chainId}`); else throw new Error(`No metadata found for chain ${chainId}`);
} }
export function getChainRpcUrl(chainId: number): string {
const metadata = getChainMetadata(chainId);
const first = metadata.publicRpcUrls[0];
return first.http;
}
export function getChainExplorerUrl(chainId: number, apiUrl = false): string { export function getChainExplorerUrl(chainId: number, apiUrl = false): string {
const metadata = getChainMetadata[chainId]; const metadata = getChainMetadata(chainId);
const first = metadata.blockExplorers[0]; const first = metadata.blockExplorers[0];
return apiUrl ? first.apiUrl || first.url : first.url; return apiUrl ? first.apiUrl || first.url : first.url;
} }
export function getChainDisplayName(chainId?: number, shortName = false): string { export function getChainDisplayName(chainId?: number, shortName = false): string {
if (!chainId) return 'Unknown'; if (!chainId) return 'Unknown';
const metadata = getChainMetadata[chainId]; const metadata = getChainMetadata(chainId);
return shortName ? metadata.displayNameShort || metadata.displayName : metadata.displayName; return shortName ? metadata.displayNameShort || metadata.displayName : metadata.displayName;
} }
// Metadata formatted for use in Wagmi config
export function getWagmiChainConfig() {
return Object.values({
...wagmiChainMetadata,
...objMap(chainIdToCustomConfig as Record<string, ChainMetadata>, toWagmiConfig),
});
}
// TODO move to SDK
function toWagmiConfig(_: any, metadata: ChainMetadata): WagmiChain {
return {
id: metadata.id,
name: metadata.displayName,
network: metadata.name as string,
nativeCurrency: metadata.nativeToken,
rpcUrls: { default: { http: [metadata.publicRpcUrls[0].http] } },
blockExplorers: metadata.blockExplorers.length
? {
default: {
name: metadata.blockExplorers[0].name,
url: metadata.blockExplorers[0].url,
},
}
: undefined,
};
}

@ -1,10 +1,15 @@
import { chainConnectionConfigs, chainIdToMetadata } from '@hyperlane-xyz/sdk'; import { providers } from 'ethers';
// This uses public RPC URLs from the SDK but can be import { getChainRpcUrl } from './chains/metadata';
// changed to use other providers as needed
const providerCache = {};
// This uses public RPC URLs from the chain configs in the SDK and/or custom settings
// Can be freely changed to use other providers/urls as needed
export function getProvider(chainId: number) { export function getProvider(chainId: number) {
const chainName = chainIdToMetadata[chainId]?.name; if (providerCache[chainId]) return providerCache[chainId];
if (!chainName) throw new Error(`No metadata found for chain: ${chainId}`); const rpcUrl = getChainRpcUrl(chainId);
// TODO support custom chains here const provider = new providers.JsonRpcProvider(rpcUrl, chainId);
return chainConnectionConfigs[chainName].provider; providerCache[chainId] = provider;
return provider;
} }

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { chainIdToMetadata } from '@hyperlane-xyz/sdk';
import { utils } from '@hyperlane-xyz/utils'; import { utils } from '@hyperlane-xyz/utils';
import { areAddressesEqual, isValidAddress, normalizeAddress } from '../../utils/addresses'; import { areAddressesEqual, isValidAddress, normalizeAddress } from '../../utils/addresses';
@ -33,7 +33,7 @@ function computeTokenRoutes(tokens: ListedTokenWithHypTokens[]) {
const tokenRoutes: RoutesMap = {}; const tokenRoutes: RoutesMap = {};
// Instantiate map structure // Instantiate map structure
const allChainIds = Object.keys(chainIdToMetadata); const allChainIds = getChainsFromTokens(tokens);
for (const source of allChainIds) { for (const source of allChainIds) {
tokenRoutes[source] = {}; tokenRoutes[source] = {};
for (const dest of allChainIds) { for (const dest of allChainIds) {
@ -88,6 +88,17 @@ function computeTokenRoutes(tokens: ListedTokenWithHypTokens[]) {
return tokenRoutes; return tokenRoutes;
} }
function getChainsFromTokens(tokens: ListedTokenWithHypTokens[]) {
const chains = new Set<number>();
for (const token of tokens) {
chains.add(token.chainId);
for (const remoteToken of token.hypTokens) {
chains.add(remoteToken.chainId);
}
}
return Array.from(chains);
}
export function getTokenRoutes( export function getTokenRoutes(
sourceChainId: number, sourceChainId: number,
destinationChainId: number, destinationChainId: number,
@ -140,8 +151,8 @@ export function useTokenRoutes() {
// TODO parallelization here would be good, either with RPC batching or just promise.all, but // TODO parallelization here would be good, either with RPC batching or just promise.all, but
// avoiding it for now due to limitations of public RPC providers // avoiding it for now due to limitations of public RPC providers
for (const chainId of domains) { for (const chainId of domains) {
const hypTokenBytes = await collateralContract.routers(chainId); const hypTokenAddrBytes = await collateralContract.routers(chainId);
const hypTokenAddr = utils.bytes32ToAddress(hypTokenBytes); const hypTokenAddr = utils.bytes32ToAddress(hypTokenAddrBytes);
hypTokens.push({ chainId, address: normalizeAddress(hypTokenAddr) }); hypTokens.push({ chainId, address: normalizeAddress(hypTokenAddr) });
} }
tokens.push({ ...token, hypTokens }); tokens.push({ ...token, hypTokens });
@ -154,3 +165,7 @@ export function useTokenRoutes() {
return { isLoading, hasError, tokenRoutes }; return { isLoading, hasError, tokenRoutes };
} }
export function useRouteChains(tokenRoutes: RoutesMap): number[] {
return useMemo(() => Object.keys(tokenRoutes).map((chainId) => parseInt(chainId)), [tokenRoutes]);
}

@ -1,10 +1,8 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Form, Formik, useFormikContext } from 'formik'; import { Form, Formik, useFormikContext } from 'formik';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useAccount } from 'wagmi'; import { useAccount } from 'wagmi';
import { chainMetadata } from '@hyperlane-xyz/sdk';
import { ConnectAwareSubmitButton } from '../../components/buttons/ConnectAwareSubmitButton'; import { ConnectAwareSubmitButton } from '../../components/buttons/ConnectAwareSubmitButton';
import { IconButton } from '../../components/buttons/IconButton'; import { IconButton } from '../../components/buttons/IconButton';
import { SolidButton } from '../../components/buttons/SolidButton'; import { SolidButton } from '../../components/buttons/SolidButton';
@ -20,22 +18,26 @@ import { logger } from '../../utils/logger';
import { ChainSelectField } from '../chains/ChainSelectField'; import { ChainSelectField } from '../chains/ChainSelectField';
import { getChainDisplayName } from '../chains/metadata'; import { getChainDisplayName } from '../chains/metadata';
import { TokenSelectField } from '../tokens/TokenSelectField'; import { TokenSelectField } from '../tokens/TokenSelectField';
import { RouteType, RoutesMap, getTokenRoute } from '../tokens/routes'; import { RouteType, RoutesMap, getTokenRoute, useRouteChains } from '../tokens/routes';
import { getCachedTokenBalance, useAccountTokenBalance } from '../tokens/useTokenBalance'; import { getCachedTokenBalance, useAccountTokenBalance } from '../tokens/useTokenBalance';
import { TransferTransactionsModal } from './TransferTransactionsModal'; import { TransferTransactionsModal } from './TransferTransactionsModal';
import { TransferFormValues } from './types'; import { TransferFormValues } from './types';
import { useTokenTransfer } from './useTokenTransfer'; import { useTokenTransfer } from './useTokenTransfer';
const initialValues: TransferFormValues = { export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) {
sourceChainId: chainMetadata.goerli.id, const chainIds = useRouteChains(tokenRoutes);
destinationChainId: chainMetadata.alfajores.id, const initialValues: TransferFormValues = useMemo(
() => ({
sourceChainId: chainIds[0],
destinationChainId: chainIds[1],
amount: '', amount: '',
tokenAddress: '', tokenAddress: '',
recipientAddress: '', recipientAddress: '',
}; }),
[chainIds],
);
export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) {
// Flag for if form is in input vs review mode // Flag for if form is in input vs review mode
const [isReview, setIsReview] = useState(false); const [isReview, setIsReview] = useState(false);
@ -57,25 +59,12 @@ export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) {
tokenAddress, tokenAddress,
recipientAddress, recipientAddress,
}: TransferFormValues) => { }: TransferFormValues) => {
// Check chains if (!sourceChainId) return { sourceChainId: 'Invalid source chain' };
if (!sourceChainId) { if (!destinationChainId) return { destinationChainId: 'Invalid destination chain' };
return { sourceChainId: 'Invalid source chain' }; if (!isValidAddress(recipientAddress)) return { recipientAddress: 'Invalid recipient' };
} if (!isValidAddress(tokenAddress)) return { tokenAddress: 'Invalid token' };
if (!destinationChainId) {
return { destinationChainId: 'Invalid destination chain' };
}
// Check addresses
if (!isValidAddress(recipientAddress)) {
return { recipientAddress: 'Invalid recipient' };
}
if (!isValidAddress(tokenAddress)) {
return { tokenAddress: 'Invalid token' };
}
// Check amount
const parsedAmount = tryParseAmount(amount); const parsedAmount = tryParseAmount(amount);
if (!parsedAmount || parsedAmount.lte(0)) { if (!parsedAmount || parsedAmount.lte(0)) return { amount: 'Invalid amount' };
return { amount: 'Invalid amount' };
}
const cachedBalance = getCachedTokenBalance( const cachedBalance = getCachedTokenBalance(
queryClient, queryClient,
sourceChainId, sourceChainId,
@ -109,7 +98,12 @@ export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) {
{({ values }) => ( {({ values }) => (
<Form className="flex flex-col items-stretch w-full mt-2"> <Form className="flex flex-col items-stretch w-full mt-2">
<div className="flex items-center justify-center space-x-7 sm:space-x-10"> <div className="flex items-center justify-center space-x-7 sm:space-x-10">
<ChainSelectField name="sourceChainId" label="From" disabled={isReview} /> <ChainSelectField
name="sourceChainId"
label="From"
chainIds={chainIds}
disabled={isReview}
/>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="flex mb-6 sm:space-x-1.5"> <div className="flex mb-6 sm:space-x-1.5">
<HyperlaneChevron <HyperlaneChevron
@ -124,7 +118,12 @@ export function TransferTokenForm({ tokenRoutes }: { tokenRoutes: RoutesMap }) {
</div> </div>
<SwapChainsButton disabled={isReview} /> <SwapChainsButton disabled={isReview} />
</div> </div>
<ChainSelectField name="destinationChainId" label="To" disabled={isReview} /> <ChainSelectField
name="destinationChainId"
label="To"
chainIds={chainIds}
disabled={isReview}
/>
</div> </div>
<div className="mt-3 flex justify-between space-x-4"> <div className="mt-3 flex justify-between space-x-4">
<div className="flex-1"> <div className="flex-1">

@ -18,16 +18,15 @@ import 'react-toastify/dist/ReactToastify.css';
import { WagmiConfig, configureChains, createClient as createWagmiClient } from 'wagmi'; import { WagmiConfig, configureChains, createClient as createWagmiClient } from 'wagmi';
import { publicProvider } from 'wagmi/providers/public'; import { publicProvider } from 'wagmi/providers/public';
import { wagmiChainMetadata } from '@hyperlane-xyz/sdk';
import { ErrorBoundary } from '../components/errors/ErrorBoundary'; import { ErrorBoundary } from '../components/errors/ErrorBoundary';
import { AppLayout } from '../components/layout/AppLayout'; import { AppLayout } from '../components/layout/AppLayout';
import { getWagmiChainConfig } from '../features/chains/metadata';
import { Color } from '../styles/Color'; import { Color } from '../styles/Color';
import '../styles/fonts.css'; import '../styles/fonts.css';
import '../styles/globals.css'; import '../styles/globals.css';
import { useIsSsr } from '../utils/ssr'; import { useIsSsr } from '../utils/ssr';
const { chains, provider } = configureChains(Object.values(wagmiChainMetadata), [publicProvider()]); const { chains, provider } = configureChains(getWagmiChainConfig(), [publicProvider()]);
const connectorConfig = { const connectorConfig = {
appName: 'Hyperlane Warp Template', appName: 'Hyperlane Warp Template',

Loading…
Cancel
Save