commit
07b53fe0da
@ -1,8 +1,9 @@ |
||||
{ |
||||
"tabWidth": 2, |
||||
"printWidth": 100, |
||||
"singleQuote": true, |
||||
"trailingComma": "all", |
||||
"importOrder": ["^@abacus-network/(.*)$", "^../(.*)$", "^./(.*)$"], |
||||
"importOrder": ["^@hyperlane-xyz/(.*)$", "^../(.*)$", "^./(.*)$"], |
||||
"importOrderSeparation": true, |
||||
"importOrderSortSpecifiers": true |
||||
} |
||||
|
@ -0,0 +1,46 @@ |
||||
import Image from 'next/future/image'; |
||||
import { ChangeEvent } from 'react'; |
||||
|
||||
import SearchIcon from '../../images/icons/search.svg'; |
||||
import XIcon from '../../images/icons/x.svg'; |
||||
import { Spinner } from '../animation/Spinner'; |
||||
import { IconButton } from '../buttons/IconButton'; |
||||
|
||||
interface Props { |
||||
value: string; |
||||
placeholder: string; |
||||
onChangeValue: (v: string) => void; |
||||
fetching: boolean; |
||||
} |
||||
|
||||
export function SearchBar({ value, placeholder, onChangeValue, fetching }: Props) { |
||||
const onChange = (event: ChangeEvent<HTMLInputElement> | null) => { |
||||
const value = event?.target?.value || ''; |
||||
onChangeValue(value); |
||||
}; |
||||
|
||||
return ( |
||||
<div className="flex items-center bg-white w-full rounded shadow-md border border-blue-50"> |
||||
<input |
||||
value={value} |
||||
onChange={onChange} |
||||
type="text" |
||||
placeholder={placeholder} |
||||
className="p-2 sm:px-4 md:px-5 flex-1 h-10 sm:h-12 rounded focus:outline-none" |
||||
/> |
||||
<div className="bg-beige-300 h-10 sm:h-12 w-10 sm:w-12 flex items-center justify-center rounded"> |
||||
{fetching && <Spinner classes="scale-[30%] mr-2.5" />} |
||||
{!fetching && !value && <Image src={SearchIcon} width={20} height={20} />} |
||||
{!fetching && value && ( |
||||
<IconButton |
||||
imgSrc={XIcon} |
||||
title="Clear search" |
||||
width={28} |
||||
height={28} |
||||
onClick={() => onChange(null)} |
||||
/> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,99 @@ |
||||
import Image from 'next/future/image'; |
||||
|
||||
import BugIcon from '../../images/icons/bug.svg'; |
||||
import ErrorIcon from '../../images/icons/error-circle.svg'; |
||||
import SearchOffIcon from '../../images/icons/search-off.svg'; |
||||
import ShrugIcon from '../../images/icons/shrug.svg'; |
||||
import { Fade } from '../animation/Fade'; |
||||
|
||||
export function SearchError({ |
||||
show, |
||||
text, |
||||
imgSrc, |
||||
imgWidth, |
||||
}: { |
||||
show: boolean; |
||||
text: string; |
||||
imgSrc: any; |
||||
imgWidth: number; |
||||
}) { |
||||
return ( |
||||
// Absolute position for overlaying cross-fade
|
||||
<div className="absolute left-0 right-0 top-10"> |
||||
<Fade show={show}> |
||||
<div className="flex justify-center my-10"> |
||||
<div className="flex flex-col items-center justify-center max-w-md px-3 py-5"> |
||||
<Image src={imgSrc} width={imgWidth} className="opacity-80" /> |
||||
<div className="mt-4 text-center leading-loose text-gray-700">{text}</div> |
||||
</div> |
||||
</div> |
||||
</Fade> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function NoSearchError({ show }: { show: boolean }) { |
||||
return ( |
||||
<SearchError |
||||
show={show} |
||||
imgSrc={BugIcon} |
||||
text="Enter a transaction hash that involved at least one Hyperlane message to begin." |
||||
imgWidth={50} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
export function SearchInvalidError({ |
||||
show, |
||||
allowAddress, |
||||
}: { |
||||
show: boolean; |
||||
allowAddress: boolean; |
||||
}) { |
||||
return ( |
||||
<SearchError |
||||
show={show} |
||||
imgSrc={SearchOffIcon} |
||||
text={`Sorry, that search input is not valid. Please try ${ |
||||
allowAddress ? 'an account addresses or ' : '' |
||||
}a transaction hash like 0xABC123...`}
|
||||
imgWidth={70} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
export function SearchEmptyError({ |
||||
show, |
||||
hasInput, |
||||
allowAddress, |
||||
}: { |
||||
show: boolean; |
||||
hasInput: boolean; |
||||
allowAddress: boolean; |
||||
}) { |
||||
return ( |
||||
<SearchError |
||||
show={show} |
||||
imgSrc={ShrugIcon} |
||||
text={`Sorry, no results found. Please try ${ |
||||
hasInput |
||||
? allowAddress |
||||
? 'a different address or transaction hash' |
||||
: 'a different transaction hash' |
||||
: 'again later' |
||||
}.`}
|
||||
imgWidth={110} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
export function SearchUnknownError({ show }: { show: boolean }) { |
||||
return ( |
||||
<SearchError |
||||
show={show} |
||||
imgSrc={ErrorIcon} |
||||
text="Sorry, an error has occurred. Please try a query or try again later." |
||||
imgWidth={70} |
||||
/> |
||||
); |
||||
} |
@ -0,0 +1,9 @@ |
||||
export enum Environment { |
||||
Mainnet = 'mainnet', |
||||
Testnet2 = 'testnet2', |
||||
} |
||||
|
||||
export const envDisplayValue = { |
||||
[Environment.Mainnet]: 'Mainnet', |
||||
[Environment.Testnet2]: 'Testnet', |
||||
}; |
@ -0,0 +1,151 @@ |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
import Image from 'next/future/image'; |
||||
import { useState } from 'react'; |
||||
|
||||
import { Fade } from '../../components/animation/Fade'; |
||||
import { CopyButton } from '../../components/buttons/CopyButton'; |
||||
import { SearchBar } from '../../components/search/SearchBar'; |
||||
import { |
||||
NoSearchError, |
||||
SearchEmptyError, |
||||
SearchInvalidError, |
||||
SearchUnknownError, |
||||
} from '../../components/search/SearchError'; |
||||
import { envDisplayValue } from '../../consts/environments'; |
||||
import ShrugIcon from '../../images/icons/shrug.svg'; |
||||
import { useStore } from '../../store'; |
||||
import useDebounce from '../../utils/debounce'; |
||||
import { sanitizeString, toTitleCase } from '../../utils/string'; |
||||
import { isValidSearchQuery } from '../search/utils'; |
||||
|
||||
import { MessageDebugResult, TxDebugStatus, debugMessageForHash } from './debugMessage'; |
||||
|
||||
export function TxDebugger() { |
||||
const environment = useStore((s) => s.environment); |
||||
|
||||
// Search text input
|
||||
const [searchInput, setSearchInput] = useState(''); |
||||
const debouncedSearchInput = useDebounce(searchInput, 750); |
||||
const hasInput = !!debouncedSearchInput; |
||||
const sanitizedInput = sanitizeString(debouncedSearchInput); |
||||
const isValidInput = isValidSearchQuery(sanitizedInput, false); |
||||
|
||||
const { |
||||
isLoading: fetching, |
||||
isError: hasError, |
||||
data, |
||||
} = useQuery( |
||||
['debugMessage', isValidInput, sanitizedInput, environment], |
||||
() => { |
||||
if (!isValidInput || !sanitizedInput) return null; |
||||
else return debugMessageForHash(sanitizedInput, environment); |
||||
}, |
||||
{ retry: false }, |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<SearchBar |
||||
value={searchInput} |
||||
onChangeValue={setSearchInput} |
||||
fetching={fetching} |
||||
placeholder="Search transaction hash to debug message" |
||||
/> |
||||
<div className="w-full h-[38.05rem] mt-5 bg-white shadow-md border border-blue-50 rounded overflow-auto relative"> |
||||
<div className="px-2 py-3 sm:px-4 md:px-5 flex items-center justify-between border-b border-gray-100"> |
||||
<h2 className="text-gray-600">{`Transaction Debugger (${envDisplayValue[environment]})`}</h2> |
||||
</div> |
||||
|
||||
<Fade show={isValidInput && !hasError && !!data}> |
||||
<div className="px-2 sm:px-4 md:px-5"> |
||||
<DebugResult result={data} /> |
||||
</div> |
||||
</Fade> |
||||
<SearchEmptyError |
||||
show={isValidInput && !hasError && !fetching && !data} |
||||
hasInput={hasInput} |
||||
allowAddress={false} |
||||
/> |
||||
<NoSearchError show={!hasInput && !hasError} /> |
||||
<SearchInvalidError show={hasInput && !hasError && !isValidInput} allowAddress={false} /> |
||||
<SearchUnknownError show={hasInput && hasError} /> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
function DebugResult({ result }: { result: MessageDebugResult | null | undefined }) { |
||||
if (!result) return null; |
||||
|
||||
if (result.status === TxDebugStatus.NotFound) { |
||||
return ( |
||||
<div className="py-12 flex flex-col items-center"> |
||||
<Image src={ShrugIcon} width={110} className="opacity-80" /> |
||||
<h2 className="mt-4 text-lg text-gray-600">No transaction found</h2> |
||||
<p className="mt-4 leading-relaxed">{result.details}</p> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (result.status === TxDebugStatus.NoMessages) { |
||||
return ( |
||||
<div className="py-12 flex flex-col items-center"> |
||||
<Image src={ShrugIcon} width={110} className="opacity-80" /> |
||||
<h2 className="mt-4 text-lg text-gray-600">No message found</h2> |
||||
<p className="mt-4 leading-relaxed">{result.details}</p> |
||||
<TxExplorerLink href={result.explorerLink} chainName={result.chainName} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (result.status === TxDebugStatus.MessagesFound) { |
||||
return ( |
||||
<> |
||||
{result.messageDetails.map((m, i) => ( |
||||
<div className="border-b border-gray-200 py-4" key={`message-${i}`}> |
||||
<h2 className="text-lg text-gray-600">{`Message ${i + 1} / ${ |
||||
result.messageDetails.length |
||||
}`}</h2>
|
||||
<p className="mt-2 leading-relaxed">{m.summary}</p> |
||||
<div className="mt-2 text-sm"> |
||||
{Array.from(m.properties.entries()).map(([key, val]) => ( |
||||
<div className="flex mt-1" key={`message-${i}-prop-${key}`}> |
||||
<label className="text-gray-600 w-32">{key}</label> |
||||
<div className="relative ml-2 truncate max-w-xs sm:max-w-sm md:max-w-lg"> |
||||
{val} |
||||
</div> |
||||
{val.length > 20 && ( |
||||
<CopyButton copyValue={val} width={12} height={12} classes="ml-2" /> |
||||
)} |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
))} |
||||
<TxExplorerLink href={result.explorerLink} chainName={result.chainName} /> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
function TxExplorerLink({ |
||||
href, |
||||
chainName, |
||||
}: { |
||||
href: string | undefined; |
||||
chainName: string | undefined; |
||||
}) { |
||||
if (!href || !chainName) return null; |
||||
return ( |
||||
<a |
||||
className="block my-5 text-blue-600 hover:text-blue-500 underline underline-offset-4" |
||||
href={href} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
> |
||||
{`View transaction in ${toTitleCase(chainName)} explorer`} |
||||
</a> |
||||
); |
||||
} |
@ -0,0 +1,247 @@ |
||||
// Based on debug script in monorepo
|
||||
// https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/typescript/infra/scripts/debug-message.ts
|
||||
import { IMessageRecipient__factory } from '@hyperlane-xyz/core'; |
||||
import { |
||||
ChainName, |
||||
DispatchedMessage, |
||||
DomainIdToChainName, |
||||
HyperlaneCore, |
||||
MultiProvider, |
||||
chainConnectionConfigs, |
||||
} from '@hyperlane-xyz/sdk'; |
||||
import { utils } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { Environment } from '../../consts/environments'; |
||||
import { errorToString } from '../../utils/errors'; |
||||
import { logger } from '../../utils/logger'; |
||||
import { chunk } from '../../utils/string'; |
||||
|
||||
export enum TxDebugStatus { |
||||
NotFound = 'notFound', |
||||
NoMessages = 'noMessages', |
||||
MessagesFound = 'messagesFound', |
||||
} |
||||
|
||||
export enum MessageDebugStatus { |
||||
NoErrorsFound = 'noErrorsFound', |
||||
InvalidDestDomain = 'invalidDestDomain', |
||||
UnknownDestChain = 'unknownDestChain', |
||||
RecipientNotContract = 'RecipientNotContract', |
||||
HandleCallFailure = 'handleCallFailure', |
||||
} |
||||
|
||||
export interface DebugNotFoundResult { |
||||
status: TxDebugStatus.NotFound; |
||||
details: string; |
||||
} |
||||
|
||||
export interface DebugNoMessagesResult { |
||||
status: TxDebugStatus.NoMessages; |
||||
chainName: string; |
||||
details: string; |
||||
explorerLink?: string; |
||||
} |
||||
|
||||
interface MessageDetails { |
||||
status: MessageDebugStatus; |
||||
properties: Map<string, string>; |
||||
summary: string; |
||||
} |
||||
|
||||
export interface DebugMessagesFoundResult { |
||||
status: TxDebugStatus.MessagesFound; |
||||
chainName: string; |
||||
explorerLink?: string; |
||||
messageDetails: MessageDetails[]; |
||||
} |
||||
|
||||
export type MessageDebugResult = |
||||
| DebugNotFoundResult |
||||
| DebugNoMessagesResult |
||||
| DebugMessagesFoundResult; |
||||
|
||||
export async function debugMessageForHash( |
||||
txHash: string, |
||||
environment: Environment, |
||||
): Promise<MessageDebugResult> { |
||||
// TODO use RPC with api keys
|
||||
const multiProvider = new MultiProvider(chainConnectionConfigs); |
||||
|
||||
const txDetails = await findTransactionDetails(txHash, multiProvider); |
||||
if (!txDetails?.transactionReceipt) { |
||||
return { |
||||
status: TxDebugStatus.NotFound, |
||||
details: 'No transaction found for this hash on any supported networks.', |
||||
}; |
||||
} |
||||
|
||||
const { transactionReceipt, chainName, explorerLink } = txDetails; |
||||
const core = HyperlaneCore.fromEnvironment(environment, multiProvider); |
||||
const dispatchedMessages = core.getDispatchedMessages(transactionReceipt); |
||||
|
||||
if (!dispatchedMessages?.length) { |
||||
return { |
||||
status: TxDebugStatus.NoMessages, |
||||
details: |
||||
'No messages found for this transaction. Please check that the hash and environment are set correctly.', |
||||
chainName, |
||||
explorerLink, |
||||
}; |
||||
} |
||||
|
||||
logger.debug(`Found ${dispatchedMessages.length} messages`); |
||||
const messageDetails: MessageDetails[] = []; |
||||
for (let i = 0; i < dispatchedMessages.length; i++) { |
||||
logger.debug(`Checking message ${i + 1} of ${dispatchedMessages.length}`); |
||||
messageDetails.push(await checkMessage(core, multiProvider, dispatchedMessages[i])); |
||||
logger.debug(`Done checking message ${i + 1}`); |
||||
} |
||||
return { |
||||
status: TxDebugStatus.MessagesFound, |
||||
chainName, |
||||
explorerLink, |
||||
messageDetails, |
||||
}; |
||||
} |
||||
|
||||
async function findTransactionDetails(txHash: string, multiProvider: MultiProvider) { |
||||
const chains = multiProvider.chains().filter((n) => !n.startsWith('test')); |
||||
const chainChunks = chunk(chains, 10); |
||||
for (const chunk of chainChunks) { |
||||
try { |
||||
const queries = chunk.map((c) => fetchTransactionDetails(txHash, multiProvider, c)); |
||||
const result = await Promise.any(queries); |
||||
return result; |
||||
} catch (error) { |
||||
logger.debug('Tx not found, trying next chunk'); |
||||
} |
||||
} |
||||
logger.debug('Tx not found on any networks'); |
||||
return null; |
||||
} |
||||
|
||||
async function fetchTransactionDetails( |
||||
txHash: string, |
||||
multiProvider: MultiProvider, |
||||
chainName: ChainName, |
||||
) { |
||||
const { provider, blockExplorerUrl } = multiProvider.getChainConnection(chainName); |
||||
// TODO explorer may be faster, more robust way to get tx and its logs
|
||||
// Note: receipt is null if tx not found
|
||||
const transactionReceipt = await provider.getTransactionReceipt(txHash); |
||||
if (transactionReceipt) { |
||||
logger.info('Tx found', txHash, chainName); |
||||
// TODO use getTxExplorerLink here, must reconcile wagmi consts and sdk consts
|
||||
const explorerLink = blockExplorerUrl ? `${blockExplorerUrl}/tx/${txHash}` : undefined; |
||||
return { transactionReceipt, chainName, explorerLink }; |
||||
} else { |
||||
logger.debug('Tx not found', txHash, chainName); |
||||
throw new Error(`Tx not found on ${chainName}`); |
||||
} |
||||
} |
||||
|
||||
async function checkMessage( |
||||
core: HyperlaneCore<any>, |
||||
multiProvider: MultiProvider<any>, |
||||
message: DispatchedMessage, |
||||
) { |
||||
logger.debug(JSON.stringify(message)); |
||||
const properties = new Map<string, string>(); |
||||
properties.set('Sender', message.parsed.sender.toString()); |
||||
properties.set('Recipient', message.parsed.sender.toString()); |
||||
properties.set('Origin Domain', message.parsed.origin.toString()); |
||||
properties.set('Destination Domain', message.parsed.destination.toString()); |
||||
properties.set('Leaf index', message.leafIndex.toString()); |
||||
properties.set('Raw Bytes', message.message); |
||||
|
||||
const destinationChain = DomainIdToChainName[message.parsed.destination]; |
||||
|
||||
if (!destinationChain) { |
||||
logger.info(`Unknown destination domain ${message.parsed.destination}`); |
||||
return { |
||||
status: MessageDebugStatus.InvalidDestDomain, |
||||
properties, |
||||
summary: |
||||
'The destination domain id is invalid. Note, domain ids usually do not match chain ids. See https://docs.hyperlane.xyz/hyperlane-docs/developers/domains', |
||||
}; |
||||
} |
||||
|
||||
logger.debug(`Destination chain: ${destinationChain}`); |
||||
|
||||
if (!core.knownChain(destinationChain)) { |
||||
logger.info(`Destination chain ${destinationChain} unknown for environment`); |
||||
return { |
||||
status: MessageDebugStatus.UnknownDestChain, |
||||
properties, |
||||
summary: `Destination chain ${destinationChain} is not included in this message's environment. See https://docs.hyperlane.xyz/hyperlane-docs/developers/domains`, |
||||
}; |
||||
} |
||||
|
||||
const destinationInbox = core.getMailboxPair( |
||||
DomainIdToChainName[message.parsed.origin], |
||||
destinationChain, |
||||
).destinationInbox; |
||||
|
||||
const messageHash = utils.messageHash(message.message, message.leafIndex); |
||||
logger.debug(`Message hash: ${messageHash}`); |
||||
|
||||
const processed = await destinationInbox.messages(messageHash); |
||||
if (processed === 1) { |
||||
logger.info('Message has already been processed'); |
||||
// TODO: look for past events to find the exact tx in which the message was processed.
|
||||
return { |
||||
status: MessageDebugStatus.NoErrorsFound, |
||||
properties, |
||||
summary: 'No errors found, this message has already been processed.', |
||||
}; |
||||
} else { |
||||
logger.debug('Message not yet processed'); |
||||
} |
||||
|
||||
const recipientAddress = utils.bytes32ToAddress(message.parsed.recipient); |
||||
const recipientIsContract = await isContract(multiProvider, destinationChain, recipientAddress); |
||||
|
||||
if (!recipientIsContract) { |
||||
logger.info(`Recipient address ${recipientAddress} is not a contract`); |
||||
return { |
||||
status: MessageDebugStatus.RecipientNotContract, |
||||
properties, |
||||
summary: `Recipient address ${recipientAddress} is not a contract. Ensure bytes32 value is not malformed.`, |
||||
}; |
||||
} |
||||
|
||||
const destinationProvider = multiProvider.getChainProvider(destinationChain); |
||||
const recipient = IMessageRecipient__factory.connect(recipientAddress, destinationProvider); |
||||
|
||||
try { |
||||
await recipient.estimateGas.handle( |
||||
message.parsed.origin, |
||||
message.parsed.sender, |
||||
message.parsed.body, |
||||
{ from: destinationInbox.address }, |
||||
); |
||||
logger.debug('Calling recipient `handle` function from the inbox does not revert'); |
||||
return { |
||||
status: MessageDebugStatus.NoErrorsFound, |
||||
properties, |
||||
summary: 'No errors found, this message appears to be deliverable.', |
||||
}; |
||||
} catch (err: any) { |
||||
logger.info(`Error calling recipient handle function from the inbox`); |
||||
const errorString = errorToString(err); |
||||
logger.debug(errorString); |
||||
return { |
||||
status: MessageDebugStatus.HandleCallFailure, |
||||
properties, |
||||
// TODO format the error string better to be easier to understand
|
||||
summary: `Error calling handle on the recipient contract. Details: ${errorString}`, |
||||
}; |
||||
} |
||||
} |
||||
|
||||
async function isContract(multiProvider: MultiProvider<any>, chain: ChainName, address: string) { |
||||
const provider = multiProvider.getChainProvider(chain); |
||||
const code = await provider.getCode(address); |
||||
// "Empty" code
|
||||
return code && code !== '0x'; |
||||
} |
After Width: | Height: | Size: 696 B |
@ -0,0 +1,14 @@ |
||||
import type { NextPage } from 'next'; |
||||
|
||||
import { ContentFrame } from '../components/layout/ContentFrame'; |
||||
import { TxDebugger } from '../features/debugger/TxDebugger'; |
||||
|
||||
const Debugger: NextPage = () => { |
||||
return ( |
||||
<ContentFrame> |
||||
<TxDebugger /> |
||||
</ContentFrame> |
||||
); |
||||
}; |
||||
|
||||
export default Debugger; |
@ -0,0 +1,19 @@ |
||||
import create from 'zustand'; |
||||
|
||||
import { Environment } from './consts/environments'; |
||||
|
||||
// Keeping everything here for now as state is simple
|
||||
// Will refactor into slices as necessary
|
||||
interface AppState { |
||||
environment: Environment; |
||||
setEnvironment: (env: Environment) => void; |
||||
bannerClassName: string; |
||||
setBanner: (env: string) => void; |
||||
} |
||||
|
||||
export const useStore = create<AppState>()((set) => ({ |
||||
environment: Environment.Mainnet, |
||||
setEnvironment: (env: Environment) => set(() => ({ environment: env })), |
||||
bannerClassName: '', |
||||
setBanner: (className: string) => set(() => ({ bannerClassName: className })), |
||||
})); |
@ -0,0 +1,10 @@ |
||||
import { trimToLength } from './string'; |
||||
|
||||
export function errorToString(error: any, maxLength = 300) { |
||||
if (!error) return 'Unknown Error'; |
||||
if (typeof error === 'string') return trimToLength(error, maxLength); |
||||
if (typeof error === 'number') return `Error code: ${error}`; |
||||
const details = error.message || error.reason || error; |
||||
if (typeof details === 'string') return trimToLength(details, maxLength); |
||||
return trimToLength(JSON.stringify(details), maxLength); |
||||
} |
@ -0,0 +1,16 @@ |
||||
import { chainIdToChain } from '../consts/networksConfig'; |
||||
|
||||
export function getTxExplorerLink(chainId: number, hash?: string) { |
||||
if (!chainId || !hash) return null; |
||||
|
||||
const chain = chainIdToChain[chainId]; |
||||
if (!chain?.blockExplorers) return null; |
||||
|
||||
if (chain.blockExplorers.etherscan) { |
||||
return `${chain.blockExplorers.etherscan.url}/tx/${hash}`; |
||||
} |
||||
if (chain.blockExplorers.default) { |
||||
return `${chain.blockExplorers.default.url}/tx/${hash}`; |
||||
} |
||||
return null; |
||||
} |
@ -1,5 +1,3 @@ |
||||
export function invertKeysAndValues(data: any) { |
||||
return Object.fromEntries( |
||||
Object.entries(data).map(([key, value]) => [value, key]), |
||||
); |
||||
return Object.fromEntries(Object.entries(data).map(([key, value]) => [value, key])); |
||||
} |
||||
|
Loading…
Reference in new issue