Validate JSON configs before use (#20)

- Validate chain and token configs with Zod
- Use MultiProvider for provider management
pull/23/head
J M Rossy 2 years ago committed by GitHub
parent 4d6982eef0
commit e68810883b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      package.json
  2. 5
      src/components/toast/TxSuccessToast.tsx
  3. 2
      src/features/chains/ChainSelectField.tsx
  4. 57
      src/features/chains/ChainSelectModal.tsx
  5. 74
      src/features/chains/metadata.ts
  6. 9
      src/features/chains/utils.ts
  7. 16
      src/features/multiProvider.ts
  8. 15
      src/features/providers.ts
  9. 36
      src/features/tokens/metadata.ts
  10. 2
      src/features/tokens/routes.ts
  11. 2
      src/features/tokens/useTokenBalance.tsx
  12. 4
      src/features/transfer/TransferTokenCard.tsx
  13. 2
      src/features/transfer/TransferTokenForm.tsx
  14. 2
      src/features/transfer/useTokenTransfer.ts
  15. 129
      src/utils/explorers.ts
  16. 3
      yarn.lock

@ -19,7 +19,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-toastify": "^9.1.1",
"wagmi": "0.11.7"
"wagmi": "0.11.7",
"zod": "^3.21.4"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.0.0",

@ -1,6 +1,6 @@
import { toast } from 'react-toastify';
import { getChainExplorerUrl } from '../../features/chains/metadata';
import { getMultiProvider } from '../../features/multiProvider';
export function toastTxSuccess(msg: string, txHash: string, chainId: number) {
toast.success(<TxSuccessToast msg={msg} txHash={txHash} chainId={chainId} />, {
@ -17,8 +17,7 @@ export function TxSuccessToast({
txHash: string;
chainId: number;
}) {
const explorerBaseUrl = getChainExplorerUrl(chainId);
const url = explorerBaseUrl ? `${explorerBaseUrl}/tx/${txHash}` : '';
const url = getMultiProvider().tryGetExplorerTxUrl(chainId, { hash: txHash });
return (
<div>
{msg + ' '}

@ -7,7 +7,7 @@ import { ChainLogo } from '@hyperlane-xyz/widgets';
import ChevronIcon from '../../images/icons/chevron-down.svg';
import { ChainSelectListModal } from './ChainSelectModal';
import { getChainDisplayName } from './metadata';
import { getChainDisplayName } from './utils';
type Props = {
name: string;

@ -1,8 +1,9 @@
import { ChainLogo } from '@hyperlane-xyz/widgets';
import { Modal } from '../../components/layout/Modal';
import { getMultiProvider } from '../multiProvider';
import { getChainDisplayName, getChainMetadata } from './metadata';
import { getChainDisplayName } from './utils';
export function ChainSelectListModal({
isOpen,
@ -22,7 +23,8 @@ export function ChainSelectListModal({
};
};
const chainMetadata = chainIds.map((c) => getChainMetadata(c));
const multiProvider = getMultiProvider();
const chainMetadata = chainIds.map((c) => multiProvider.getChainMetadata(c));
return (
<Modal isOpen={isOpen} title="Select Chain" close={close}>
@ -41,54 +43,3 @@ export function ChainSelectListModal({
</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)}
// >
// <ChainLogo 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)}
// >
// <ChainLogo chainId={c.id} size={16} background={false} />
// <span className="ml-2">{getChainDisplayName(c.id, true)}</span>
// </button>
// ))}
// </div>
// </div>
// </Modal>
// );
// }

@ -1,61 +1,39 @@
import type { Chain as WagmiChain } from '@wagmi/core';
import { z } from 'zod';
import {
ChainMap,
ChainMetadata,
chainIdToMetadata,
ChainMetadataSchema,
chainMetadata,
chainMetadataToWagmiChain,
objMap,
} from '@hyperlane-xyz/sdk';
import CustomChainConfig from '../../consts/chains.json';
export type CustomChainMetadata = ChainMetadata & {
logoImgSrc: string;
};
export const chainIdToCustomConfig = Object.values(CustomChainConfig).reduce<
Record<number, CustomChainMetadata>
>((result, config) => {
result[config.chainId] = config as CustomChainMetadata;
return result;
}, {});
// TODO use MultiProvider here
export function getChainMetadata(chainId: number): ChainMetadata {
if (chainIdToCustomConfig[chainId]) return chainIdToCustomConfig[chainId];
else if (chainIdToMetadata[chainId]) return chainIdToMetadata[chainId];
else throw new Error(`No metadata found for chain ${chainId}`);
}
// TODO use MultiProvider here
export function getChainRpcUrl(chainId: number): string {
const metadata = getChainMetadata(chainId);
const first = metadata.publicRpcUrls[0];
return first.http;
}
// TODO use MultiProvider here
export function getChainExplorerUrl(chainId: number, apiUrl = false): string | null {
const metadata = getChainMetadata(chainId);
const first = metadata.blockExplorers?.[0];
if (!first) return null;
return apiUrl ? first.apiUrl || first.url : first.url;
}
// TODO use MultiProvider here
export function getChainDisplayName(chainId?: number, shortName = false): string {
if (!chainId) return 'Unknown';
const metadata = getChainMetadata(chainId);
const displayName = shortName
? metadata.displayNameShort || metadata.displayName
: metadata.displayName;
return displayName || metadata.name;
import { logger } from '../../utils/logger';
export const ChainMetadataExtensionSchema = z.object({
logoImgSrc: z.string(),
});
export type CustomChainMetadata = ChainMetadata & z.infer<typeof ChainMetadataExtensionSchema>;
export const ChainConfigSchema = z.record(ChainMetadataSchema.merge(ChainMetadataExtensionSchema));
let chainConfigs: ChainMap<ChainMetadata | CustomChainMetadata>;
export function getChainConfigs() {
if (!chainConfigs) {
const result = ChainConfigSchema.safeParse(CustomChainConfig);
if (!result.success) {
logger.error('Invalid chain config', result.error);
throw new Error(`Invalid chain config: ${result.error.toString()}`);
}
const customChainConfigs = result.data as ChainMap<CustomChainMetadata>;
chainConfigs = { ...chainMetadata, ...customChainConfigs };
}
return chainConfigs;
}
// Metadata formatted for use in Wagmi config
export function getWagmiChainConfig(): WagmiChain[] {
return Object.values({
...objMap(chainIdToMetadata, (_: any, m: ChainMetadata) => chainMetadataToWagmiChain(m)),
...objMap(chainIdToCustomConfig, (_: any, m: ChainMetadata) => chainMetadataToWagmiChain(m)),
});
return Object.values(getChainConfigs()).map(chainMetadataToWagmiChain);
}

@ -0,0 +1,9 @@
import { toTitleCase } from '../../utils/string';
import { getMultiProvider } from '../multiProvider';
export function getChainDisplayName(chainId?: number, shortName = false) {
const metadata = getMultiProvider().tryGetChainMetadata(chainId || 0);
if (!metadata) return 'Unknown';
const displayName = shortName ? metadata.displayNameShort : metadata.displayName;
return toTitleCase(displayName || metadata.displayName || metadata.name);
}

@ -0,0 +1,16 @@
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { getChainConfigs } from './chains/metadata';
let multiProvider: MultiProvider;
export function getMultiProvider() {
if (!multiProvider) {
multiProvider = new MultiProvider(getChainConfigs());
}
return multiProvider;
}
export function getProvider(chainId: number) {
return getMultiProvider().getProvider(chainId);
}

@ -1,15 +0,0 @@
import { providers } from 'ethers';
import { getChainRpcUrl } from './chains/metadata';
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): providers.JsonRpcProvider {
if (providerCache[chainId]) return providerCache[chainId];
const rpcUrl = getChainRpcUrl(chainId);
const provider = new providers.JsonRpcProvider(rpcUrl, chainId);
providerCache[chainId] = provider;
return provider;
}

@ -1,12 +1,44 @@
import { z } from 'zod';
import SyntheticTokenList from '../../consts/tokens.json';
import { areAddressesEqual } from '../../utils/addresses';
import { logger } from '../../utils/logger';
/**
* Zod schema for Token config validation
*/
const TokenSchema = z.object({
chainId: z.number(),
address: z.string(),
name: z.string(),
symbol: z.string(),
decimals: z.number(),
logoURI: z.string(),
hypCollateralAddress: z.string(),
});
const TokenListSchema = z.object({
tokens: z.array(TokenSchema),
});
export type TokenMetadata = z.infer<typeof TokenSchema>;
let tokens: TokenMetadata[];
export function getAllTokens() {
return SyntheticTokenList.tokens;
if (!tokens) {
const result = TokenListSchema.safeParse(SyntheticTokenList);
if (!result.success) {
logger.error('Invalid token config', result.error);
throw new Error(`Invalid token config: ${result.error.toString()}`);
}
tokens = result.data.tokens;
}
return tokens;
}
export function getTokenMetadata(chainId: number, tokenAddress: Address) {
return SyntheticTokenList.tokens.find(
return getAllTokens().find(
(t) => t.chainId == chainId && areAddressesEqual(t.address, tokenAddress),
);
}

@ -7,7 +7,7 @@ import { areAddressesEqual, isValidAddress, normalizeAddress } from '../../utils
import { logger } from '../../utils/logger';
import { getErc20Contract } from '../contracts/erc20';
import { getHypErc20CollateralContract } from '../contracts/hypErc20';
import { getProvider } from '../providers';
import { getProvider } from '../multiProvider';
import { getAllTokens } from './metadata';
import { ListedTokenWithHypTokens } from './types';

@ -3,7 +3,7 @@ import { useAccount } from 'wagmi';
import { logger } from '../../utils/logger';
import { getErc20Contract } from '../contracts/erc20';
import { getProvider } from '../providers';
import { getProvider } from '../multiProvider';
export function getTokenBalanceKey(
chainId: number,

@ -35,10 +35,10 @@ export function TransferTokenCard() {
</div>
)}
{routesError && (
<div className="my-32 flex flex-col items-center">
<div className="my-32 flex flex-col items-center text-center">
<h3 className="text-red-500">Error searching for token routes.</h3>
<div className="mt-3 text-sm text-red-500">
Please ensure synthetic token list is valid.
Please ensure chain and token configs are valid.
</div>
<div className="mt-4 text-xs text-gray-500">{routesError.toString()}</div>
</div>

@ -17,7 +17,7 @@ import { isValidAddress } from '../../utils/addresses';
import { fromWeiRounded, toWei, tryParseAmount } from '../../utils/amount';
import { logger } from '../../utils/logger';
import { ChainSelectField } from '../chains/ChainSelectField';
import { getChainDisplayName } from '../chains/metadata';
import { getChainDisplayName } from '../chains/utils';
import { TokenSelectField } from '../tokens/TokenSelectField';
import { RouteType, RoutesMap, getTokenRoute, useRouteChains } from '../tokens/routes';
import { getCachedTokenBalance, useAccountTokenBalance } from '../tokens/useTokenBalance';

@ -11,7 +11,7 @@ import { logger } from '../../utils/logger';
import { sleep } from '../../utils/timeout';
import { getErc20Contract } from '../contracts/erc20';
import { getHypErc20CollateralContract, getHypErc20Contract } from '../contracts/hypErc20';
import { getProvider } from '../providers';
import { getProvider } from '../multiProvider';
import { RouteType, RoutesMap, getTokenRoute } from '../tokens/routes';
import { TransferFormValues } from './types';

@ -1,129 +0,0 @@
import { BigNumber, providers } from 'ethers';
import { config } from '../consts/config';
import { getChainExplorerUrl } from '../features/chains/metadata';
import { logger } from './logger';
import { retryAsync } from './retry';
import { fetchWithTimeout } from './timeout';
export interface ExplorerQueryResponse<R> {
status: string;
message: string;
result: R;
}
export function getTxExplorerUrl(chainId: number, hash?: string) {
const baseUrl = getChainExplorerUrl(chainId);
if (!hash || !baseUrl) return null;
return `${baseUrl}/tx/${hash}`;
}
export async function queryExplorer<P>(chainId: number, path: string, useKey = true) {
const baseUrl = getChainExplorerUrl(chainId, true);
if (!baseUrl) throw new Error(`No URL found for explorer for chain ${chainId}`);
let url = `${baseUrl}/${path}`;
logger.debug('Querying explorer url:', url);
if (useKey) {
const apiKey = config.explorerApiKeys[chainId];
if (!apiKey) throw new Error(`No API key for explorer for chain ${chainId}`);
url += `&apikey=${apiKey}`;
}
const result = await retryAsync(() => executeQuery<P>(url), 2, 1000);
return result;
}
async function executeQuery<P>(url: string) {
const response = await fetchWithTimeout(url);
if (!response.ok) {
throw new Error(`Fetch response not okay: ${response.status}`);
}
const json = (await response.json()) as ExplorerQueryResponse<P>;
if (!json.result) {
const responseText = await response.text();
throw new Error(`Invalid result format: ${responseText}`);
}
return json.result;
}
export interface ExplorerLogEntry {
address: string;
topics: string[];
data: string;
blockNumber: string;
timeStamp: string;
gasPrice: string;
gasUsed: string;
logIndex: string;
transactionHash: string;
transactionIndex: string;
}
export async function queryExplorerForLogs(
chainId: number,
path: string,
topic0: string,
useKey = true,
): Promise<ExplorerLogEntry[]> {
const logs = await queryExplorer<ExplorerLogEntry[]>(chainId, path, useKey);
if (!logs || !Array.isArray(logs)) {
const msg = 'Invalid tx logs result';
logger.error(msg, JSON.stringify(logs), path);
throw new Error(msg);
}
logs.forEach((l) => validateExplorerLog(l, topic0));
return logs;
}
export function validateExplorerLog(log: ExplorerLogEntry, topic0?: string) {
if (!log) throw new Error('Log is nullish');
if (!log.transactionHash) throw new Error('Log has no tx hash');
if (!log.topics || !log.topics.length) throw new Error('Log has no topics');
if (topic0 && log.topics[0]?.toLowerCase() !== topic0) throw new Error('Log topic is incorrect');
if (!log.data) throw new Error('Log has no data to parse');
if (!log.timeStamp) throw new Error('Log has no timestamp');
}
export async function queryExplorerForTx(chainId: number, txHash: string, useKey = true) {
const path = `api?module=proxy&action=eth_getTransactionByHash&txhash=${txHash}`;
const tx = await queryExplorer<providers.TransactionResponse>(chainId, path, useKey);
if (!tx || tx.hash.toLowerCase() !== txHash.toLowerCase()) {
const msg = 'Invalid tx result';
logger.error(msg, JSON.stringify(tx), path);
throw new Error(msg);
}
return tx;
}
export async function queryExplorerForTxReceipt(chainId: number, txHash: string, useKey = true) {
const path = `api?module=proxy&action=eth_getTransactionReceipt&txhash=${txHash}`;
const tx = await queryExplorer<providers.TransactionReceipt>(chainId, path, useKey);
if (!tx || tx.transactionHash.toLowerCase() !== txHash.toLowerCase()) {
const msg = 'Invalid tx result';
logger.error(msg, JSON.stringify(tx), path);
throw new Error(msg);
}
return tx;
}
export async function queryExplorerForBlock(
chainId: number,
blockNumber?: number | string,
useKey = true,
) {
const path = `api?module=proxy&action=eth_getBlockByNumber&tag=${
blockNumber || 'latest'
}&boolean=false`;
const block = await queryExplorer<providers.Block>(chainId, path, useKey);
if (!block || BigNumber.from(block.number).lte(0)) {
const msg = 'Invalid block result';
logger.error(msg, JSON.stringify(block), path);
throw new Error(msg);
}
return block;
}

@ -1058,6 +1058,7 @@ __metadata:
ts-node: ^10.9.1
typescript: ^4.9.5
wagmi: 0.11.7
zod: ^3.21.4
languageName: unknown
linkType: soft
@ -9150,7 +9151,7 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.21.2":
"zod@npm:^3.21.2, zod@npm:^3.21.4":
version: 3.21.4
resolution: "zod@npm:3.21.4"
checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f

Loading…
Cancel
Save