Validate JSON configs before use (#20)
- Validate chain and token configs with Zod - Use MultiProvider for provider managementpull/23/head
parent
4d6982eef0
commit
e68810883b
@ -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), |
||||
); |
||||
} |
||||
|
@ -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; |
||||
} |
Loading…
Reference in new issue