Merge pull request #4 from hyperlane-xyz/tx-debugger

Add tx debugger
pull/5/head
J M Rossy 2 years ago committed by GitHub
commit 07b53fe0da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      .prettierrc
  2. 6
      package.json
  3. 4
      src/components/animation/Spinner.tsx
  4. 12
      src/components/buttons/ConnectAwareSubmitButton.tsx
  5. 11
      src/components/buttons/IconButton.tsx
  6. 3
      src/components/buttons/SolidButton.tsx
  7. 14
      src/components/icons/ChainIcon.tsx
  8. 2
      src/components/layout/AppLayout.tsx
  9. 22
      src/components/layout/BackgroundBanner.tsx
  10. 18
      src/components/layout/ContentFrame.tsx
  11. 13
      src/components/nav/Footer.tsx
  12. 143
      src/components/nav/Header.tsx
  13. 46
      src/components/search/SearchBar.tsx
  14. 99
      src/components/search/SearchError.tsx
  15. 22
      src/consts/appConfig.ts
  16. 9
      src/consts/environments.ts
  17. 9
      src/consts/networksConfig.ts
  18. 151
      src/features/debugger/TxDebugger.tsx
  19. 247
      src/features/debugger/debugMessage.ts
  20. 73
      src/features/search/MessageDetails.tsx
  21. 142
      src/features/search/MessageSearch.tsx
  22. 31
      src/features/search/MessageSummary.tsx
  23. 9
      src/features/search/placeholderMessages.ts
  24. 19
      src/features/search/query.ts
  25. 25
      src/features/search/utils.ts
  26. 4
      src/images/icons/bug.svg
  27. 41
      src/pages/_app.tsx
  28. 30
      src/pages/_document.tsx
  29. 14
      src/pages/debugger.tsx
  30. 4
      src/pages/message/[messageId].tsx
  31. 19
      src/store.ts
  32. 3
      src/styles/globals.css
  33. 4
      src/utils/addresses.ts
  34. 10
      src/utils/errors.ts
  35. 16
      src/utils/explorers.ts
  36. 4
      src/utils/objects.ts
  37. 17
      src/utils/string.ts
  38. 9
      src/utils/timeout.ts
  39. 163
      yarn.lock

@ -1,8 +1,9 @@
{
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all",
"importOrder": ["^@abacus-network/(.*)$", "^../(.*)$", "^./(.*)$"],
"importOrder": ["^@hyperlane-xyz/(.*)$", "^../(.*)$", "^./(.*)$"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}

@ -4,9 +4,10 @@
"version": "0.1.0",
"author": "J M Rossy",
"dependencies": {
"@abacus-network/sdk": "^0.4.1",
"@hyperlane-xyz/sdk": "^0.5.0-beta0",
"@metamask/jazzicon": "https://github.com/jmrossy/jazzicon#7a8df28974b4e81129bfbe3cab76308b889032a6",
"@rainbow-me/rainbowkit": "^0.4.5",
"@tanstack/react-query": "^4.6.0",
"buffer": "^6.0.3",
"ethers": "^5.6.8",
"formik": "^2.2.9",
@ -17,7 +18,8 @@
"react-dom": "^18.2.0",
"react-toastify": "^9.0.5",
"urql": "^3.0.1",
"wagmi": "^0.5.11"
"wagmi": "^0.5.11",
"zustand": "^4.1.1"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^3.2.0",

@ -5,9 +5,7 @@ import styles from './Spinner.module.css';
// From https://loading.io/css/
function _Spinner({ white, classes }: { white?: boolean; classes?: string }) {
return (
<div
className={`${styles.spinner} ${white && styles.white} ${classes || ''}`}
>
<div className={`${styles.spinner} ${white && styles.white} ${classes || ''}`}>
<div></div>
<div></div>
<div></div>

@ -19,19 +19,13 @@ export function ConnectAwareSubmitButton<FormValues = any>(props: Props) {
const isAccountReady = !!(address && isConnected && connector);
const { errors, setErrors, touched, setTouched } =
useFormikContext<FormValues>();
const { errors, setErrors, touched, setTouched } = useFormikContext<FormValues>();
const hasError =
Object.keys(touched).length > 0 && Object.keys(errors).length > 0;
const hasError = Object.keys(touched).length > 0 && Object.keys(errors).length > 0;
const firstError = `${Object.values(errors)[0]}` || 'Unknown error';
const color = hasError ? 'red' : 'blue';
const text = hasError
? firstError
: isAccountReady
? connectText
: 'Connect Wallet';
const text = hasError ? firstError : isAccountReady ? connectText : 'Connect Wallet';
const type = isAccountReady ? 'submit' : 'button';
const onClick = isAccountReady ? undefined : openConnectModal;

@ -13,16 +13,7 @@ export interface IconButtonProps {
}
export function IconButton(props: PropsWithChildren<IconButtonProps>) {
const {
width,
height,
classes,
onClick,
imgSrc,
disabled,
title,
passThruProps,
} = props;
const { width, height, classes, onClick, imgSrc, disabled, title, passThruProps } = props;
const base = 'flex items-center justify-center transition-all';
const onHover = 'hover:opacity-70';

@ -28,8 +28,7 @@ export function SolidButton(props: PropsWithChildren<ButtonProps>) {
} = props;
const color = _color ?? 'blue';
const base =
'flex items-center justify-center rounded-full transition-all duration-1000';
const base = 'flex items-center justify-center rounded-full transition-all duration-1000';
const sizing = sizeToClasses(size);
let baseColors, onHover, onActive;
if (color === 'blue') {

@ -2,11 +2,7 @@ import Image from 'next/future/image';
import { memo } from 'react';
import { chain } from 'wagmi';
import {
avalancheChain,
bscChain,
celoMainnetChain,
} from '../../consts/networksConfig';
import { avalancheChain, bscChain, celoMainnetChain } from '../../consts/networksConfig';
import QuestionMark from '../../images/icons/question-mark.svg';
import Arbitrum from '../../images/logos/arbitrum.svg';
import Avalanche from '../../images/logos/avalanche.svg';
@ -27,13 +23,7 @@ const CHAIN_TO_ICON = {
[chain.polygon.id]: Polygon,
};
function _ChainIcon({
chainId,
size = 44,
}: {
chainId?: number;
size?: number;
}) {
function _ChainIcon({ chainId, size = 44 }: { chainId?: number; size?: number }) {
const imageSrc = (chainId && CHAIN_TO_ICON[chainId]) || QuestionMark;
return (

@ -19,7 +19,7 @@ export function AppLayout({ pathName, children }: PropsWithChildren<Props>) {
</Head>
<div className="h-full min-h-screen w-full min-w-screen bg-gray-100">
<div className="max-w-5xl mx-auto flex flex-col justify-between min-h-screen">
<Header />
<Header pathName={pathName} />
<main className="w-full flex-1">{children}</main>
<Footer />
</div>

@ -1,29 +1,13 @@
import { createContext, useContext, useMemo, useState } from 'react';
import { useStore } from '../../store';
import { classNameToColor } from '../../styles/Color';
import { WideChevronIcon } from '../icons/WideChevron';
export const BannerColorContext = createContext<{
bannerClassName: string;
setBannerClassName?: (name: string) => void;
}>({ bannerClassName: '', setBannerClassName: undefined });
export function useBackgroundBannerState() {
// State for managing banner class, to be used as context value
const [bannerClassName, setBannerClassName] = useState('');
const bannerState = useMemo(
() => ({ bannerClassName, setBannerClassName }),
[bannerClassName, setBannerClassName],
);
return bannerState;
}
export function useBackgroundBanner() {
return useContext(BannerColorContext);
return useStore((s) => s.bannerClassName);
}
export function BackgroundBanner() {
const { bannerClassName } = useBackgroundBanner();
const bannerClassName = useStore((s) => s.bannerClassName);
const colorClass = bannerClassName || 'bg-blue-500';
return (

@ -1,25 +1,13 @@
import { PropsWithChildren } from 'react';
import {
BackgroundBanner,
BannerColorContext,
useBackgroundBannerState,
} from './BackgroundBanner';
import { BackgroundBanner } from './BackgroundBanner';
export function ContentFrame(props: PropsWithChildren) {
// Provide context so children can change banner color
const bannerState = useBackgroundBannerState();
return (
<div className="flex flex-col justify-center items-center min-h-full">
<div
style={styles.container}
className="relative overflow-visible mt-7 mb-8"
>
<BannerColorContext.Provider value={bannerState}>
<div style={styles.container} className="relative overflow-visible mt-7 mb-8">
<BackgroundBanner />
<div className="relative z-20">{props.children}</div>
</BannerColorContext.Provider>
<div className="relative z-20 mt-1">{props.children}</div>
</div>
</div>
);

@ -19,8 +19,7 @@ export function Footer() {
</div>
<div className="flex flex-col ml-3">
<p className="text-sm font-light">
<span className="text-base font-medium">Hyperlane</span> is the
platform
<span className="text-base font-medium">Hyperlane</span> is the platform
<br />
for developers building
<br />
@ -41,15 +40,7 @@ export function Footer() {
);
}
function FooterIconLink({
to,
imgSrc,
text,
}: {
to: string;
imgSrc: any;
text: string;
}) {
function FooterIconLink({ to, imgSrc, text }: { to: string; imgSrc: any; text: string }) {
return (
<a
href={to}

@ -2,63 +2,50 @@ import Image from 'next/future/image';
import Link from 'next/link';
import useDropdownMenu from 'react-accessible-dropdown-menu-hook';
import { Environment, allConfigs, config } from '../../consts/appConfig';
import { Environment, envDisplayValue } from '../../consts/environments';
import { links } from '../../consts/links';
import BookIcon from '../../images/icons/book.svg';
import BugIcon from '../../images/icons/bug.svg';
import HamburgerIcon from '../../images/icons/hamburger.svg';
import HouseIcon from '../../images/icons/house.svg';
import HubIcon from '../../images/icons/hub.svg';
import InfoIcon from '../../images/icons/info-circle.svg';
import Logo from '../../images/logos/hyperlane-logo.svg';
import Name from '../../images/logos/hyperlane-name.svg';
import { useStore } from '../../store';
import { SelectField } from '../input/SelectField';
export function Header() {
export function Header({ pathName }: { pathName: string }) {
const { buttonProps, itemProps, isOpen, setIsOpen } = useDropdownMenu(4);
const closeDropdown = () => {
setIsOpen(false);
};
const isMainnet = config.environment === Environment.Mainnet;
return (
<header className="p-2 sm:py-3 sm:pl-6 sm:pr-8 w-full">
<header className="p-2 sm:py-3 sm:pl-6 sm:pr-10 w-full">
<div className="flex items-center justify-between">
<Link href="/">
<a className="flex items-center">
<div className="flex items-center scale-90 sm:scale-100">
<div className="relative">
<Image src={Logo} width={24} height={28} />
</div>
<div className="relative pt-1 ml-2">
<Image src={Name} width={124} height={28} alt="Hyperlane" />
</div>
<div className="font-serif text-[1.9rem] text-blue-500 sm:ml-2 pt-px">
Explorer
</div>
<Image src={Name} width={124} height={28} alt="Hyperlane" className="ml-2 pt-px" />
<div className="font-serif text-[1.85rem] text-blue-500 ml-2">Explorer</div>
</div>
</a>
</Link>
<div className="hidden sm:flex sm:space-x-8 sm:items-center md:space-x-12">
<nav className="hidden sm:flex sm:space-x-8 sm:items-center md:space-x-10">
<Link href="/">
<a className={styles.navLink}>Home</a>
<a className={styles.navLink + (pathName === '/' ? ' underline' : '')}>Home</a>
</Link>
<a
className={styles.navLink}
target="_blank"
href={links.docs}
rel="noopener noreferrer"
>
Docs
<Link href="/debugger">
<a className={styles.navLink + (pathName === '/debugger' ? ' underline' : '')}>
Debugger
</a>
<a
className={styles.navLink}
target="_blank"
href={links.home}
rel="noopener noreferrer"
>
</Link>
<a className={styles.navLink} target="_blank" href={links.home} rel="noopener noreferrer">
About
</a>
<NetworkSelector />
</div>
</nav>
<div className="relative flex item-center sm:hidden mr-2">
<button className="hover:opactiy-70 transition-all" {...buttonProps}>
<Image src={HamburgerIcon} width={22} height={22} />
@ -66,21 +53,19 @@ export function Header() {
</div>
</div>
{/* Dropdown menu, used on mobile */}
<div
className={`${styles.dropdownContainer} ${!isOpen && 'hidden'} right-0`}
role="menu"
>
<nav className={`${styles.dropdownContainer} ${!isOpen && 'hidden'} right-0`} role="menu">
<Link href="/">
<a
{...itemProps[0]}
className={styles.dropdownOption}
onClick={closeDropdown}
>
<a {...itemProps[0]} className={styles.dropdownOption} onClick={closeDropdown}>
<DropdownItemContent icon={HouseIcon} text="Home" />
</a>
</Link>
<Link href="/debugger">
<a {...itemProps[1]} className={styles.dropdownOption} onClick={closeDropdown}>
<DropdownItemContent icon={BugIcon} text="Debug" />
</a>
</Link>
<a
{...itemProps[1]}
{...itemProps[2]}
onClick={closeDropdown}
className={styles.dropdownOption}
target="_blank"
@ -90,7 +75,7 @@ export function Header() {
<DropdownItemContent icon={BookIcon} text="Docs" />
</a>
<a
{...itemProps[2]}
{...itemProps[3]}
onClick={closeDropdown}
className={styles.dropdownOption}
target="_blank"
@ -99,20 +84,7 @@ export function Header() {
>
<DropdownItemContent icon={InfoIcon} text="About" />
</a>
<a
{...itemProps[3]}
onClick={closeDropdown}
className={styles.dropdownOption}
href={isMainnet ? allConfigs.testnet2.url : allConfigs.mainnet.url}
target="_blank"
rel="noopener noreferrer"
>
<DropdownItemContent
icon={HubIcon}
text={isMainnet ? 'Testnet' : 'Mainnet'}
/>
</a>
</div>
</nav>
</header>
);
}
@ -127,59 +99,32 @@ function DropdownItemContent({ icon, text }: { icon: any; text: string }) {
}
function NetworkSelector() {
const { buttonProps, itemProps, isOpen, setIsOpen } = useDropdownMenu(2);
const { environment, setEnvironment } = useStore((s) => ({
environment: s.environment,
setEnvironment: s.setEnvironment,
}));
return (
<div className="relative">
<button
className="flex items-center pb-px hover:opacity-60 transition-all"
{...buttonProps}
>
<Image src={HubIcon} width={20} height={20} className="opacity-70" />
</button>
<div
className={`${styles.dropdownContainer} ${!isOpen && 'hidden'} right-0`}
role="menu"
>
<a
{...itemProps[0]}
className={`${styles.dropdownOption} justify-center ${
config.environment === Environment.Mainnet && styles.activeEnv
}`}
onClick={() => setIsOpen(false)}
href={
config.environment !== Environment.Mainnet
? allConfigs.mainnet.url
: undefined
}
target="_blank"
rel="noopener noreferrer"
>
Mainnet
</a>
<a
{...itemProps[1]}
onClick={() => setIsOpen(false)}
className={`${styles.dropdownOption} justify-center ${
config.environment === Environment.Testnet2 && styles.activeEnv
}`}
href={
config.environment !== Environment.Testnet2
? allConfigs.testnet2.url
: undefined
}
target="_blank"
rel="noopener noreferrer"
>
Testnet
</a>
</div>
{/* <Image src={HubIcon} width={20} height={20} className="opacity-70" /> */}
<SelectField
classes="w-24 text-gray-600 border-gray-600 text-[0.95rem]"
options={envOptions}
value={environment}
onValueSelect={(e: string) => setEnvironment(e as Environment)}
/>
</div>
);
}
const envOptions = [
{ value: Environment.Mainnet, display: envDisplayValue[Environment.Mainnet] },
{ value: Environment.Testnet2, display: envDisplayValue[Environment.Testnet2] },
];
const styles = {
navLink:
'flex items-center tracking-wide text-gray-600 text-[0.95rem] hover:underline hover:opacity-70 decoration-2 underline-offset-4 transition-all',
'flex items-center tracking-wide text-gray-600 text-[0.95rem] hover:underline hover:opacity-70 decoration-2 underline-offset-[6px] transition-all',
dropdownContainer: 'dropdown-menu w-[7.5rem] mt-1 mr-px bg-gray-50',
dropdownOption:
'flex items-center cursor-pointer p-2 mt-1 rounded text-gray-600 hover:underline decoration-2 underline-offset-4 transition-all',

@ -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}
/>
);
}

@ -1,36 +1,26 @@
export enum Environment {
Mainnet = 'mainnet',
Testnet2 = 'testnet2',
}
import { Environment } from './environments';
// Toggle for testnet2 vs mainnet
const environment: Environment = Environment.Mainnet;
const isDevMode = process?.env?.NODE_ENV === 'development';
const version = process?.env?.NEXT_PUBLIC_VERSION ?? null;
export const allConfigs: Record<Environment, Config> = {
export const configs: Record<Environment, Config> = {
mainnet: {
name: 'Hyperlane Explorer',
environment: Environment.Mainnet,
debug: isDevMode,
version,
url: 'https://hyperlane-explorer.vercel.app/',
apiUrl: 'https://abacus-explorer-api.hasura.app/v1/graphql',
url: 'https://explorer.hyperlane.xyz',
apiUrl: 'https://abacus-explorer-api.hasura.app/v1/graphql', // TODO change
},
testnet2: {
name: 'Hyperlane Testnet Explorer',
environment: Environment.Testnet2,
debug: true,
version,
url: 'TODO',
apiUrl: 'TODO',
url: 'https://explorer.hyperlane.xyz',
apiUrl: 'https://abacus-explorer-api.hasura.app/v1/graphql', // TODO change
},
};
export const config = Object.freeze(allConfigs[environment]);
interface Config {
name: string;
environment: Environment;
debug: boolean;
version: string | null;

@ -0,0 +1,9 @@
export enum Environment {
Mainnet = 'mainnet',
Testnet2 = 'testnet2',
}
export const envDisplayValue = {
[Environment.Mainnet]: 'Mainnet',
[Environment.Testnet2]: 'Testnet',
};

@ -1,6 +1,6 @@
import { Chain, allChains as allChainsWagmi, chain } from 'wagmi';
import { chainConnectionConfigs } from '@abacus-network/sdk';
import { chainConnectionConfigs } from '@hyperlane-xyz/sdk';
export const testConfigs = {
goerli: chainConnectionConfigs.goerli,
@ -123,10 +123,7 @@ export const allChains = [
celoAlfajoresChain,
];
export const chainIdToChain = allChains.reduce<Record<number, Chain>>(
(result, chain) => {
export const chainIdToChain = allChains.reduce<Record<number, Chain>>((result, chain) => {
result[chain.id] = chain;
return result;
},
{},
);
}, {});

@ -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';
}

@ -8,13 +8,14 @@ import { CopyButton } from '../../components/buttons/CopyButton';
import { ChainIcon } from '../../components/icons/ChainIcon';
import { ChainToChain } from '../../components/icons/ChainToChain';
import { HelpIcon } from '../../components/icons/HelpIcon';
import { useBackgroundBanner } from '../../components/layout/BackgroundBanner';
import { Card } from '../../components/layout/Card';
import { chainToDomain } from '../../consts/domains';
import CheckmarkIcon from '../../images/icons/checkmark-circle.svg';
import ErrorCircleIcon from '../../images/icons/error-circle.svg';
import { useStore } from '../../store';
import { MessageStatus, PartialTransactionReceipt } from '../../types';
import { getChainName } from '../../utils/chains';
import { getTxExplorerLink } from '../../utils/explorers';
import { logger } from '../../utils/logger';
import { getDateTimeString } from '../../utils/time';
import { useInterval } from '../../utils/timeout';
@ -22,7 +23,6 @@ import { useInterval } from '../../utils/timeout';
import { PLACEHOLDER_MESSAGES } from './placeholderMessages';
import { parseMessageQueryResult } from './query';
import { MessagesQueryResult } from './types';
import { getTxExplorerLink } from './utils';
const AUTO_REFRESH_DELAY = 10000;
@ -48,28 +48,25 @@ export function MessageDetails({ messageId }: { messageId: string }) {
destinationTransaction,
} = message;
const { bannerClassName, setBannerClassName } = useBackgroundBanner();
const setBanner = useStore((s) => s.setBanner);
useEffect(() => {
if (!setBannerClassName || fetching) return;
if (fetching) return;
if (error) {
logger.error('Error fetching message details', error);
toast.error(`Error fetching message: ${error.message?.substring(0, 30)}`);
setBannerClassName('bg-red-600');
setBanner('bg-red-600');
} else if (message.status === MessageStatus.Failing) {
setBannerClassName('bg-red-600');
setBanner('bg-red-600');
} else if (!isMessageFound) {
setBannerClassName('bg-gray-500');
} else if (bannerClassName) {
setBannerClassName('');
setBanner('bg-gray-500');
} else {
setBanner('');
}
}, [
error,
fetching,
message,
isMessageFound,
bannerClassName,
setBannerClassName,
]);
}, [error, fetching, message, isMessageFound, setBanner]);
useEffect(() => {
return () => setBanner('');
}, [setBanner]);
const reExecutor = useCallback(() => {
if (!isMessageFound || status !== MessageStatus.Delivered) {
@ -174,10 +171,7 @@ function TransactionCard({
help,
shouldBlur,
}: TransactionCardProps) {
const txExplorerLink = getTxExplorerLink(
chainId,
transaction?.transactionHash,
);
const txExplorerLink = getTxExplorerLink(chainId, transaction?.transactionHash);
return (
<Card classes="flex-1 min-w-fit space-y-4">
<div className="flex items-center justify-between">
@ -194,9 +188,7 @@ function TransactionCard({
<ValueRow
label="Chain:"
labelWidth="w-16"
display={`${getChainName(chainId)} (${chainId} / ${
chainToDomain[chainId]
})`}
display={`${getChainName(chainId)} (${chainId} / ${chainToDomain[chainId]})`}
displayWidth="w-44 sm:w-56"
blurValue={shouldBlur}
/>
@ -219,9 +211,7 @@ function TransactionCard({
<ValueRow
label="Block:"
labelWidth="w-16"
display={`${transaction.blockNumber} (${getDateTimeString(
transaction.timestamp,
)})`}
display={`${transaction.blockNumber} (${getDateTimeString(transaction.timestamp)})`}
displayWidth="w-44 sm:w-56"
blurValue={shouldBlur}
/>
@ -271,15 +261,10 @@ function DetailsCard({
<Card classes="mt-2 space-y-4">
<div className="flex items-center justify-between">
<div className="relative -top-px -left-0.5">
<ChainToChain
originChainId={originChainId}
destinationChainId={destinationChainId}
/>
<ChainToChain originChainId={originChainId} destinationChainId={destinationChainId} />
</div>
<div className="flex items-center pb-1">
<h3 className="text-gray-500 font-medium text-md mr-2">
Message Details
</h3>
<h3 className="text-gray-500 font-medium text-md mr-2">Message Details</h3>
<HelpIcon size={16} text={helpText.details} />
</div>
</div>
@ -333,24 +318,16 @@ function ValueRow({
return (
<div className="flex items-center pl-px">
<label className={`text-sm text-gray-500 ${labelWidth}`}>{label}</label>
<span
className={`text-sm ml-2 truncate ${displayWidth} ${
blurValue && 'blur-xs'
}`}
>
<span className={`text-sm ml-2 truncate ${displayWidth} ${blurValue && 'blur-xs'}`}>
{display}
</span>
{showCopy && (
<CopyButton copyValue={display} width={15} height={15} classes="ml-3" />
)}
{showCopy && <CopyButton copyValue={display} width={15} height={15} classes="ml-3" />}
</div>
);
}
function ErrorIcon() {
return (
<Image src={ErrorCircleIcon} width={24} height={24} className="invert" />
);
return <Image src={ErrorCircleIcon} width={24} height={24} className="invert" />;
}
const messageDetailsQuery = `
@ -410,10 +387,8 @@ query MessageDetails ($messageId: bigint!){
}`;
const helpText = {
origin:
'Info about the transaction that initiated the message placement into the outbox.',
origin: 'Info about the transaction that initiated the message placement into the outbox.',
destination:
'Info about the transaction that triggered the delivery of the message from an inbox.',
details:
'Immutable information about the message itself such as its contents.',
details: 'Immutable information about the message itself such as its contents.',
};

@ -1,21 +1,20 @@
import Image from 'next/future/image';
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { useQuery } from 'urql';
import { Fade } from '../../components/animation/Fade';
import { Spinner } from '../../components/animation/Spinner';
import { IconButton } from '../../components/buttons/IconButton';
import { SelectField } from '../../components/input/SelectField';
import { SearchBar } from '../../components/search/SearchBar';
import {
SearchEmptyError,
SearchInvalidError,
SearchUnknownError,
} from '../../components/search/SearchError';
import { chainToDomain } from '../../consts/domains';
import { prodChains } from '../../consts/networksConfig';
import ArrowRightIcon from '../../images/icons/arrow-right-short.svg';
import ErrorIcon from '../../images/icons/error-circle.svg';
import FunnelIcon from '../../images/icons/funnel.svg';
import SearchOffIcon from '../../images/icons/search-off.svg';
import SearchIcon from '../../images/icons/search.svg';
import ShrugIcon from '../../images/icons/shrug.svg';
import XIcon from '../../images/icons/x.svg';
import { trimLeading0x } from '../../utils/addresses';
import useDebounce from '../../utils/debounce';
import { sanitizeString, trimToLength } from '../../utils/string';
@ -45,14 +44,10 @@ export function MessageSearch() {
// Search text input
const [searchInput, setSearchInput] = useState('');
const onChangeSearch = (event: ChangeEvent<HTMLInputElement> | null) => {
const value = event?.target?.value || '';
setSearchInput(value);
};
const debouncedSearchInput = useDebounce(searchInput, 750);
const hasInput = !!debouncedSearchInput;
const sanitizedInput = sanitizeString(debouncedSearchInput);
const isValidInput = hasInput ? isValidSearchQuery(sanitizedInput) : true;
const isValidInput = hasInput ? isValidSearchQuery(sanitizedInput, true) : true;
// Filter state and handlers
const chainOptions = useMemo(getChainOptionList, []);
@ -88,57 +83,26 @@ export function MessageSearch() {
return (
<>
{/* Search bar */}
<div className="flex items-center bg-white w-full rounded shadow-md border border-blue-50">
<input
<SearchBar
value={searchInput}
onChange={onChangeSearch}
type="text"
onChangeValue={setSearchInput}
fetching={fetching}
placeholder="Search for messages by address or transaction hash"
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 && !searchInput && (
<Image src={SearchIcon} width={20} height={20} />
)}
{!fetching && searchInput && (
<IconButton
imgSrc={XIcon}
title="Clear search"
width={28}
height={28}
onClick={() => onChangeSearch(null)}
/>
)}
</div>
</div>
<div className="w-full h-[38.05rem] mt-5 bg-white shadow-md border border-blue-50 rounded overflow-auto relative">
{/* Content header and filter bar */}
<div className="px-2 py-3 sm:px-4 md:px-5 md:py-3 flex items-center justify-between border-b border-gray-100">
<h2 className="text-gray-500 black-shadow">
{!hasInput ? 'Latest Messages' : 'Search Results'}
</h2>
<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">{!hasInput ? 'Latest Messages' : 'Search Results'}</h2>
<div className="flex items-center space-x-1 sm:space-x-2 md:space-x-3">
<div className="w-px h-8 bg-gray-100"></div>
<Image
src={FunnelIcon}
width={22}
height={22}
className="hidden sm:block opacity-50"
/>
<Image src={FunnelIcon} width={22} height={22} className="hidden sm:block opacity-50" />
<SelectField
classes="w-24 md:w-32"
options={chainOptions}
value={originChainFilter}
onValueSelect={onChangeOriginFilter}
/>
<Image
src={ArrowRightIcon}
width={30}
height={30}
className="opacity-50"
/>
<Image src={ArrowRightIcon} width={30} height={30} className="opacity-50" />
<SelectField
classes="w-24 md:w-32"
options={chainOptions}
@ -160,72 +124,16 @@ export function MessageSearch() {
</div>
))}
</Fade>
{/* Invalid input state */}
<SearchInfoBox
show={!isValidInput}
imgSrc={SearchOffIcon}
imgAlt="Search invalid"
text="Sorry, that search input is not valid. Please try an account
addresses or a transaction hash like 0x123..."
imgWidth={70}
/>
{/* No results state */}
<SearchInfoBox
show={
!hasError && !fetching && isValidInput && messageList.length === 0
}
imgSrc={ShrugIcon}
imgAlt="No results"
text={`Sorry, no results found. Please try ${
hasInput ? 'a different address or hash' : 'again later'
}.`}
imgWidth={110}
/>
{/* Search error state */}
<SearchInfoBox
show={hasError && isValidInput}
imgSrc={ErrorIcon}
imgAlt="Error"
text="Sorry, an error has occurred. Please try a query or try again later."
imgWidth={70}
/>
</div>
</>
);
}
function SearchInfoBox({
show,
text,
imgSrc,
imgAlt,
imgWidth,
}: {
show: boolean;
text: string;
imgSrc: any;
imgAlt: string;
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}
alt={imgAlt}
width={imgWidth}
className="opacity-80"
<SearchInvalidError show={!isValidInput} allowAddress={true} />
<SearchUnknownError show={isValidInput && hasError} />
<SearchEmptyError
show={isValidInput && !hasError && !fetching && messageList.length === 0}
hasInput={hasInput}
allowAddress={true}
/>
<div className="mt-4 text-center leading-loose text-gray-700 black-shadow">
{text}
</div>
</div>
</div>
</Fade>
</div>
</>
);
}
@ -239,11 +147,7 @@ function getChainOptionList(): Array<{ value: string; display: string }> {
];
}
function assembleQuery(
searchInput: string,
originFilter: string,
destFilter: string,
) {
function assembleQuery(searchInput: string, originFilter: string, destFilter: string) {
const hasInput = !!searchInput;
const variables = {
search: hasInput ? trimLeading0x(searchInput) : undefined,

@ -6,15 +6,7 @@ import { shortenAddress } from '../../utils/addresses';
import { getHumanReadableTimeString } from '../../utils/time';
export function MessageSummary({ message }: { message: MessageStub }) {
const {
id,
status,
sender,
recipient,
timestamp,
originChainId,
destinationChainId,
} = message;
const { id, status, sender, recipient, timestamp, originChainId, destinationChainId } = message;
let statusColor = 'bg-beige-500';
let statusText = 'Pending';
@ -29,33 +21,22 @@ export function MessageSummary({ message }: { message: MessageStub }) {
return (
<Link href={`/message/${id}`}>
<a className="flex items-center justify-between space-x-4 xs:space-x-9 sm:space-x-12 md:space-x-16">
<ChainToChain
originChainId={originChainId}
destinationChainId={destinationChainId}
/>
<ChainToChain originChainId={originChainId} destinationChainId={destinationChainId} />
<div className="flex items-center justify-between flex-1">
<div className={styles.valueContainer}>
<div className={styles.label}>Sender</div>
<div className={styles.value}>
{shortenAddress(sender) || 'Invalid Address'}
</div>
<div className={styles.value}>{shortenAddress(sender) || 'Invalid Address'}</div>
</div>
<div className="hidden sm:flex flex-col">
<div className={styles.label}>Recipient</div>
<div className={styles.value}>
{shortenAddress(recipient) || 'Invalid Address'}
</div>
<div className={styles.value}>{shortenAddress(recipient) || 'Invalid Address'}</div>
</div>
<div className={styles.valueContainer + ' sm:w-28'}>
<div className={styles.label}>Time sent</div>
<div className={styles.value}>
{getHumanReadableTimeString(timestamp)}
</div>
<div className={styles.value}>{getHumanReadableTimeString(timestamp)}</div>
</div>
</div>
<div
className={`w-20 md:w-24 py-2 text-sm text-center rounded ${statusColor}`}
>
<div className={`w-20 md:w-24 py-2 text-sm text-center rounded ${statusColor}`}>
{statusText}
</div>
</a>

@ -1,15 +1,10 @@
import { constants } from 'ethers';
import { chain } from 'wagmi';
import {
avalancheChain,
bscChain,
celoMainnetChain,
} from '../../consts/networksConfig';
import { avalancheChain, bscChain, celoMainnetChain } from '../../consts/networksConfig';
import { Message, MessageStatus, PartialTransactionReceipt } from '../../types';
const TX_HASH_ZERO =
'0x0000000000000000000000000000000000000000000000000000000000000000';
const TX_HASH_ZERO = '0x0000000000000000000000000000000000000000000000000000000000000000';
export const TX_ZERO: PartialTransactionReceipt = {
from: constants.AddressZero,

@ -1,10 +1,5 @@
import { domainToChain } from '../../consts/domains';
import {
Message,
MessageStatus,
MessageStub,
PartialTransactionReceipt,
} from '../../types';
import { Message, MessageStatus, MessageStub, PartialTransactionReceipt } from '../../types';
import { ensureLeading0x } from '../../utils/addresses';
import { logger } from '../../utils/logger';
@ -16,18 +11,12 @@ import {
TransactionEntry,
} from './types';
export function parseMessageStubResult(
data: MessagesStubQueryResult | undefined,
): MessageStub[] {
export function parseMessageStubResult(data: MessagesStubQueryResult | undefined): MessageStub[] {
if (!data?.message?.length) return [];
return data.message
.map(parseMessageStub)
.filter((m): m is MessageStub => !!m);
return data.message.map(parseMessageStub).filter((m): m is MessageStub => !!m);
}
export function parseMessageQueryResult(
data: MessagesQueryResult | undefined,
): Message[] {
export function parseMessageQueryResult(data: MessagesQueryResult | undefined): Message[] {
if (!data?.message?.length) return [];
return data.message.map(parseMessage).filter((m): m is Message => !!m);
}

@ -1,27 +1,8 @@
import { chainIdToChain } from '../../consts/networksConfig';
import {
isValidAddressFast,
isValidTransactionHash,
} from '../../utils/addresses';
import { isValidAddressFast, isValidTransactionHash } from '../../utils/addresses';
export function isValidSearchQuery(input: string) {
export function isValidSearchQuery(input: string, allowAddress?: boolean) {
if (!input) return false;
if (isValidTransactionHash(input)) return true;
if (isValidAddressFast(input)) return true;
if (allowAddress && isValidAddressFast(input)) return true;
return false;
}
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;
}

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M4.978.855a.5.5 0 1 0-.956.29l.41 1.352A4.985 4.985 0 0 0 3 6h10a4.985 4.985 0 0 0-1.432-3.503l.41-1.352a.5.5 0 1 0-.956-.29l-.291.956A4.978 4.978 0 0 0 8 1a4.979 4.979 0 0 0-2.731.811l-.29-.956z"/>
<path d="M13 6v1H8.5v8.975A5 5 0 0 0 13 11h.5a.5.5 0 0 1 .5.5v.5a.5.5 0 1 0 1 0v-.5a1.5 1.5 0 0 0-1.5-1.5H13V9h1.5a.5.5 0 0 0 0-1H13V7h.5A1.5 1.5 0 0 0 15 5.5V5a.5.5 0 0 0-1 0v.5a.5.5 0 0 1-.5.5H13zm-5.5 9.975V7H3V6h-.5a.5.5 0 0 1-.5-.5V5a.5.5 0 0 0-1 0v.5A1.5 1.5 0 0 0 2.5 7H3v1H1.5a.5.5 0 0 0 0 1H3v1h-.5A1.5 1.5 0 0 0 1 11.5v.5a.5.5 0 1 0 1 0v-.5a.5.5 0 0 1 .5-.5H3a5 5 0 0 0 4.5 4.975z"/>
</svg>

After

Width:  |  Height:  |  Size: 696 B

@ -5,24 +5,20 @@ import {
wallet,
} from '@rainbow-me/rainbowkit';
import '@rainbow-me/rainbowkit/styles.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { AppProps } from 'next/app';
import { ToastContainer, Zoom, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import {
Provider as UrqlProvider,
createClient as createUrqlClient,
} from 'urql';
import {
WagmiConfig,
configureChains,
createClient as createWagmiClient,
} from 'wagmi';
import { Client, Provider as UrqlProvider, createClient as createUrqlClient } from 'urql';
import { WagmiConfig, configureChains, createClient as createWagmiClient } from 'wagmi';
import { publicProvider } from 'wagmi/providers/public';
import { ErrorBoundary } from '../components/errors/ErrorBoundary';
import { AppLayout } from '../components/layout/AppLayout';
import { config } from '../consts/appConfig';
import { configs } from '../consts/appConfig';
import { Environment } from '../consts/environments';
import { prodChains } from '../consts/networksConfig';
import { useStore } from '../store';
import { Color } from '../styles/Color';
import '../styles/fonts.css';
import '../styles/globals.css';
@ -48,11 +44,20 @@ const wagmiClient = createWagmiClient({
connectors,
});
const urqlClient = createUrqlClient({
url: config.apiUrl,
});
const urqlClients: Record<Environment, Client> = {
[Environment.Mainnet]: createUrqlClient({
url: configs.mainnet.apiUrl,
}),
[Environment.Testnet2]: createUrqlClient({
url: configs.testnet2.apiUrl,
}),
};
const reactQueryClient = new QueryClient();
export default function App({ Component, router, pageProps }: AppProps) {
const environment = useStore((s) => s.environment);
// Disable app SSR for now as it's not needed and
// complicates graphql integration
const isSsr = useIsSsr();
@ -72,17 +77,15 @@ export default function App({ Component, router, pageProps }: AppProps) {
fontStack: 'system',
})}
>
<UrqlProvider value={urqlClient}>
<QueryClientProvider client={reactQueryClient}>
<UrqlProvider value={urqlClients[environment]}>
<AppLayout pathName={pathName}>
<Component {...pageProps} />
</AppLayout>
</UrqlProvider>
</QueryClientProvider>
</RainbowKitProvider>
<ToastContainer
transition={Zoom}
position={toast.POSITION.BOTTOM_RIGHT}
limit={2}
/>
<ToastContainer transition={Zoom} position={toast.POSITION.BOTTOM_RIGHT} limit={2} />
</WagmiConfig>
</ErrorBoundary>
);

@ -6,23 +6,9 @@ export default function Document() {
<Head>
<meta charSet="utf-8" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#025aa1" />
<link rel="shortcut icon" href="/favicon.ico" />
@ -30,10 +16,7 @@ export default function Document() {
<meta name="theme-color" content="#ffffff" />
<meta name="application-name" content="Hyperlane Explorer" />
<meta
name="keywords"
content="Hyperlane Explorer App Multi-chain Cross-chain"
/>
<meta name="keywords" content="Hyperlane Explorer App Multi-chain Cross-chain" />
<meta
name="description"
content="A multi-chain explorer for the Hyperlane protocol and network"
@ -46,10 +29,7 @@ export default function Document() {
<meta property="og:url" content="https://explorer.hyperlane.xyz" />
<meta property="og:title" content="Hyperlane Explorer" />
<meta property="og:type" content="website" />
<meta
property="og:image"
content="https://explorer.hyperlane.xyz/logo-with-text.png"
/>
<meta property="og:image" content="https://explorer.hyperlane.xyz/logo-with-text.png" />
<meta
property="og:description"
content="A multi-chain explorer for the Hyperlane protocol and network"

@ -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;

@ -12,9 +12,7 @@ const Message: NextPage = () => {
useEffect(() => {
if (!messageId || typeof messageId !== 'string')
router
.replace('/')
.catch((e) => logger.error('Error routing back to home', e));
router.replace('/').catch((e) => logger.error('Error routing back to home', e));
}, [router, messageId]);
if (!messageId || typeof messageId !== 'string') return null;

@ -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 })),
}));

@ -145,8 +145,7 @@ Dropdowns
padding: 0.5rem 0.6rem;
border: 1px solid #f1f1f1;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
transition: transform 0.2s ease, opacity 0.2s ease, visibility 0s linear 0.2s;
will-change: transform;
}

@ -37,9 +37,7 @@ export function shortenAddress(address: string, capitalize?: boolean) {
try {
const normalized = normalizeAddress(address);
const shortened =
normalized.substring(0, 6) +
'...' +
normalized.substring(normalized.length - 4);
normalized.substring(0, 6) + '...' + normalized.substring(normalized.length - 4);
return capitalize ? capitalizeAddress(shortened) : shortened;
} catch (error) {
logger.error('Unable to shorten invalid address', address, error);

@ -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]));
}

@ -15,7 +15,18 @@ export function sanitizeString(str: string) {
export function trimToLength(value: string, maxLength: number) {
if (!value) return '';
const trimmed = value.trim();
return trimmed.length > maxLength
? trimmed.substring(0, maxLength) + '...'
: trimmed;
return trimmed.length > maxLength ? trimmed.substring(0, maxLength) + '...' : trimmed;
}
interface Sliceable {
length: number;
slice: (i: number, j: number) => any;
}
export function chunk<T extends Sliceable>(str: T, size: number) {
const R: Array<T> = [];
for (let i = 0; i < str.length; i += size) {
R.push(str.slice(i, i + size));
}
return R;
}

@ -67,17 +67,12 @@ export async function fetchWithTimeout(
}
export function sleep(milliseconds: number) {
return new Promise((resolve) =>
setTimeout(() => resolve(true), milliseconds),
);
return new Promise((resolve) => setTimeout(() => resolve(true), milliseconds));
}
export const PROMISE_TIMEOUT = '__promise_timeout__';
export async function promiseTimeout<T>(
promise: Promise<T>,
milliseconds: number,
) {
export async function promiseTimeout<T>(promise: Promise<T>, milliseconds: number) {
// Create a promise that rejects in <ms> milliseconds
const timeout = new Promise<T>((_resolve, reject) => {
const id = setTimeout(() => {

@ -5,64 +5,6 @@ __metadata:
version: 6
cacheKey: 8
"@abacus-network/app@npm:0.4.1":
version: 0.4.1
resolution: "@abacus-network/app@npm:0.4.1"
dependencies:
"@abacus-network/core": 0.4.1
"@abacus-network/utils": 0.4.1
"@openzeppelin/contracts-upgradeable": ^4.5.0
checksum: bf1dc46b769bfef8ef2f1b568b5ea23f5c4148e68e3771d95510adb919b1ea030cff9080f9fc8a05889f973b1ff718b783414d8e5e86749e78f56799c38fe4df
languageName: node
linkType: hard
"@abacus-network/celo-ethers-provider@npm:^0.1.0":
version: 0.1.0
resolution: "@abacus-network/celo-ethers-provider@npm:0.1.0"
peerDependencies:
ethers: ^5
checksum: 06f440366bbd9ddf9962aa46c471be1fb587909c4da71a0d816497a5c0c8597e9b315d70fe9ae728ed51fc936097da50da2cd18f750861f3d602660b8ea1ecfd
languageName: node
linkType: hard
"@abacus-network/core@npm:0.4.1":
version: 0.4.1
resolution: "@abacus-network/core@npm:0.4.1"
dependencies:
"@abacus-network/utils": 0.4.1
"@openzeppelin/contracts": ^4.6.0
"@openzeppelin/contracts-upgradeable": ^4.6.0
"@summa-tx/memview-sol": ^2.0.0
checksum: 5fdeb32eb33ad6a470b4b7f801ed419da2065cb6c6f155d651576cd66379663e3d1b35f9b9016d581b1ffd7aa038d7cdab8f8574bc259d801bc317753de41e1d
languageName: node
linkType: hard
"@abacus-network/sdk@npm:^0.4.1":
version: 0.4.1
resolution: "@abacus-network/sdk@npm:0.4.1"
dependencies:
"@abacus-network/app": 0.4.1
"@abacus-network/celo-ethers-provider": ^0.1.0
"@abacus-network/core": 0.4.1
"@abacus-network/utils": 0.4.1
"@types/debug": ^4.1.7
coingecko-api: ^1.0.10
cross-fetch: ^3.1.5
debug: ^4.3.4
ethers: ^5.6.8
checksum: d6f43d26666b07711ab82688b4b5d5b3de718019223b048b753dbf04ca38eed79bc3f634c9de66592babb86b1cfcf3250dc7065e3ae8bb3386a98ca7aed7258c
languageName: node
linkType: hard
"@abacus-network/utils@npm:0.4.1":
version: 0.4.1
resolution: "@abacus-network/utils@npm:0.4.1"
dependencies:
ethers: ^5.6.8
checksum: dd69c911619412b138e12a1bfa1c52a57e56b40cdd416122cb98a4c04032295978b45cfecc47b8038af72d01eab9811245cfb6e3d50cd21ec11b263f426e6b1a
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.18.6":
version: 7.18.6
resolution: "@babel/code-frame@npm:7.18.6"
@ -904,13 +846,46 @@ __metadata:
languageName: node
linkType: hard
"@hyperlane-xyz/app@npm:0.5.0-beta0":
version: 0.5.0-beta0
resolution: "@hyperlane-xyz/app@npm:0.5.0-beta0"
dependencies:
"@hyperlane-xyz/core": 0.5.0-beta0
"@hyperlane-xyz/utils": 0.5.0-beta0
"@openzeppelin/contracts-upgradeable": ^4.5.0
checksum: 14dac843eb418bd9807116bb0dd56aca08bd4a8645cd635f534dc75bb8fbfd1b5a12c1d1b2e9343d12592798e827f191c48725d0e6d943e2e9d7fe3bd3fddf47
languageName: node
linkType: hard
"@hyperlane-xyz/celo-ethers-provider@npm:^0.1.1":
version: 0.1.1
resolution: "@hyperlane-xyz/celo-ethers-provider@npm:0.1.1"
peerDependencies:
ethers: ^5
checksum: 5ea495505b3e4338ec6c419f69b66af2d35b86e7cdab9bc7ee65ba4a233729b2143b0a72cc637f8e8795ecacc18eeda5ac803e8b3de9a63e7ceb5dc14014b3d4
languageName: node
linkType: hard
"@hyperlane-xyz/core@npm:0.5.0-beta0":
version: 0.5.0-beta0
resolution: "@hyperlane-xyz/core@npm:0.5.0-beta0"
dependencies:
"@hyperlane-xyz/utils": 0.5.0-beta0
"@openzeppelin/contracts": ^4.6.0
"@openzeppelin/contracts-upgradeable": ^4.6.0
"@summa-tx/memview-sol": ^2.0.0
checksum: 7c3f88d4140d63b50013feddd1a13275e28eeb1ff1e7cd119480aee904a11c6ff0b50535159e90058b6a80cf28176a7ef0bdd13a20e99f82ef722a34b807a193
languageName: node
linkType: hard
"@hyperlane-xyz/explorer@workspace:.":
version: 0.0.0-use.local
resolution: "@hyperlane-xyz/explorer@workspace:."
dependencies:
"@abacus-network/sdk": ^0.4.1
"@hyperlane-xyz/sdk": ^0.5.0-beta0
"@metamask/jazzicon": "https://github.com/jmrossy/jazzicon#7a8df28974b4e81129bfbe3cab76308b889032a6"
"@rainbow-me/rainbowkit": ^0.4.5
"@tanstack/react-query": ^4.6.0
"@trivago/prettier-plugin-sort-imports": ^3.2.0
"@types/node": 18.0.4
"@types/react": 18.0.15
@ -937,9 +912,36 @@ __metadata:
typescript: ^4.7.4
urql: ^3.0.1
wagmi: ^0.5.11
zustand: ^4.1.1
languageName: unknown
linkType: soft
"@hyperlane-xyz/sdk@npm:^0.5.0-beta0":
version: 0.5.0-beta0
resolution: "@hyperlane-xyz/sdk@npm:0.5.0-beta0"
dependencies:
"@hyperlane-xyz/app": 0.5.0-beta0
"@hyperlane-xyz/celo-ethers-provider": ^0.1.1
"@hyperlane-xyz/core": 0.5.0-beta0
"@hyperlane-xyz/utils": 0.5.0-beta0
"@types/debug": ^4.1.7
coingecko-api: ^1.0.10
cross-fetch: ^3.1.5
debug: ^4.3.4
ethers: ^5.6.8
checksum: 85ef5c70ad290f1ff67297fdd76419d8b4290b22c3a1eb9ed5c1ac4876c7fef5c11471a063190aa8940156bc622e6527950d24aac9e58897cd301da33c41c1c7
languageName: node
linkType: hard
"@hyperlane-xyz/utils@npm:0.5.0-beta0":
version: 0.5.0-beta0
resolution: "@hyperlane-xyz/utils@npm:0.5.0-beta0"
dependencies:
ethers: ^5.6.8
checksum: 7628b0624a1b7bbab77b04ada38d0842dee8076273f58c0cd608877b430e46f2d965de0293d53b6bdcfe66b4fb36840ded0ee2a9e30894394415be38817e223f
languageName: node
linkType: hard
"@jridgewell/gen-mapping@npm:^0.3.2":
version: 0.3.2
resolution: "@jridgewell/gen-mapping@npm:0.3.2"
@ -1256,6 +1258,32 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/query-core@npm:4.6.0":
version: 4.6.0
resolution: "@tanstack/query-core@npm:4.6.0"
checksum: 945a3b1ddc89ddf484f828cee0be5db0939f51865b2518f8c4ef351c6a3fc992f8c9fbbd32a5bf16b97d0250e95f43c1e571d5a1dfefd99ab835ffe0f934b159
languageName: node
linkType: hard
"@tanstack/react-query@npm:^4.6.0":
version: 4.6.0
resolution: "@tanstack/react-query@npm:4.6.0"
dependencies:
"@tanstack/query-core": 4.6.0
use-sync-external-store: ^1.2.0
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
react-native: "*"
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
checksum: 39b9f5a71b1c699927db515419889b04e60410472481c5b08645eda8310c73c0c8acfcdffd9d63a5730eefff5e74a4a84939082fbe467d3af0571d837978fbb5
languageName: node
linkType: hard
"@tootallnate/once@npm:2":
version: 2.0.0
resolution: "@tootallnate/once@npm:2.0.0"
@ -7179,3 +7207,20 @@ __metadata:
checksum: d4506fa171a9b2eab14071590903150d8b2c4f8b97b71c08cbc63da172a1b79fee9bcdf33833b6242ba5cee302430013437892bb02b2534dfe1622d9fd11790e
languageName: node
linkType: hard
"zustand@npm:^4.1.1":
version: 4.1.1
resolution: "zustand@npm:4.1.1"
dependencies:
use-sync-external-store: 1.2.0
peerDependencies:
immer: ">=9.0"
react: ">=16.8"
peerDependenciesMeta:
immer:
optional: true
react:
optional: true
checksum: 03eefb193e2ecb43a761c81cb60f517c2780289dab0f55f2cbdb91400924c6291abb5a007b32ee19787b8f72b6769f891a38b64fd296660c170d632c3182f6e1
languageName: node
linkType: hard

Loading…
Cancel
Save