Migrate explorer from SDK consts to registry

Update to latest hyperlane libs
pull/72/head
J M Rossy 7 months ago
parent 2ed49779b7
commit 84a9a3420a
  1. 2
      next.config.js
  2. 7
      package.json
  3. 25
      src/components/icons/ChainLogo.tsx
  4. 39
      src/components/icons/ChainToChain.tsx
  5. 12
      src/components/nav/Header.tsx
  6. 38
      src/components/search/SearchFilterBar.tsx
  7. 3
      src/consts/environments.ts
  8. 1
      src/consts/links.ts
  9. 7
      src/features/api/getMessages.ts
  10. 6
      src/features/api/getStatus.ts
  11. 6
      src/features/api/searchMessages.ts
  12. 9
      src/features/api/utils.ts
  13. 46
      src/features/chains/ConfigureChains.tsx
  14. 36
      src/features/chains/utils.ts
  15. 7
      src/features/debugger/debugMessage.ts
  16. 6
      src/features/deliveryStatus/fetchDeliveryStatus.ts
  17. 10
      src/features/deliveryStatus/useMessageDeliveryStatus.tsx
  18. 3
      src/features/messages/MessageDetails.tsx
  19. 2
      src/features/messages/MessageTable.tsx
  20. 2
      src/features/messages/cards/ContentDetailsCard.tsx
  21. 2
      src/features/messages/cards/GasDetailsCard.tsx
  22. 2
      src/features/messages/cards/TransactionCard.tsx
  23. 2
      src/features/messages/ica.ts
  24. 7
      src/features/messages/pi-queries/fetchPiChainMessages.test.ts
  25. 2
      src/features/messages/pi-queries/usePiChainMessageQuery.ts
  26. 3
      src/features/messages/queries/parse.ts
  27. 2
      src/features/messages/queries/useMessageQuery.ts
  28. 5
      src/features/providers/multiProvider.ts
  29. 5
      src/pages/api/latest-nonce.ts
  30. 40
      src/store.ts
  31. 2806
      yarn.lock

@ -25,7 +25,7 @@ const securityHeaders = [
key: 'Content-Security-Policy', key: 'Content-Security-Policy',
value: `default-src 'self'; script-src 'self'${ value: `default-src 'self'; script-src 'self'${
isDev ? " 'unsafe-eval'" : '' isDev ? " 'unsafe-eval'" : ''
}; connect-src *; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; base-uri 'self'; form-action 'self'`, }; connect-src *; img-src 'self' data: https://raw.githubusercontent.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; base-uri 'self'; form-action 'self'`,
}, },
] ]

@ -1,12 +1,13 @@
{ {
"name": "@hyperlane-xyz/explorer", "name": "@hyperlane-xyz/explorer",
"description": "An interchain explorer for the Hyperlane protocol and network.", "description": "An interchain explorer for the Hyperlane protocol and network.",
"version": "3.8.0", "version": "3.11.0",
"author": "J M Rossy", "author": "J M Rossy",
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.17", "@headlessui/react": "^1.7.17",
"@hyperlane-xyz/sdk": "3.8.0", "@hyperlane-xyz/registry": "^1.1.0",
"@hyperlane-xyz/utils": "3.8.0", "@hyperlane-xyz/sdk": "3.11.1",
"@hyperlane-xyz/utils": "3.11.1",
"@hyperlane-xyz/widgets": "3.8.0", "@hyperlane-xyz/widgets": "3.8.0",
"@metamask/jazzicon": "https://github.com/jmrossy/jazzicon#7a8df28974b4e81129bfbe3cab76308b889032a6", "@metamask/jazzicon": "https://github.com/jmrossy/jazzicon#7a8df28974b4e81129bfbe3cab76308b889032a6",
"@rainbow-me/rainbowkit": "0.12.16", "@rainbow-me/rainbowkit": "0.12.16",

@ -1,13 +1,22 @@
import { ComponentProps } from 'react';
import { ChainLogo as ChainLogoInner } from '@hyperlane-xyz/widgets'; import { ChainLogo as ChainLogoInner } from '@hyperlane-xyz/widgets';
import { getChainName } from '../../features/chains/utils'; import { useMultiProvider, useRegistry } from '../../store';
import { useMultiProvider } from '../../features/providers/multiProvider';
export function ChainLogo(props: ComponentProps<typeof ChainLogoInner>) { export function ChainLogo({
const { chainName, chainId, ...rest } = props; chainId,
chainName,
background,
size,
}: {
chainId: ChainId;
chainName?: string;
background?: boolean;
size?: number;
}) {
const multiProvider = useMultiProvider(); const multiProvider = useMultiProvider();
const name = chainName || getChainName(multiProvider, props.chainId); const registry = useRegistry();
return <ChainLogoInner {...rest} chainName={name} chainId={chainId} />; const name = chainName || multiProvider.tryGetChainName(chainId) || '';
return (
<ChainLogoInner chainName={name} registry={registry} size={size} background={background} />
);
} }

@ -1,39 +0,0 @@
import Image from 'next/image';
import { memo } from 'react';
import ArrowRightIcon from '../../images/icons/arrow-right-short.svg';
import { useIsMobile } from '../../styles/mediaQueries';
import { ChainLogo } from './ChainLogo';
function ChainToChain_({
originChainId,
destinationChainId,
size = 32,
arrowSize = 32,
isNarrow = false,
}: {
originChainId: ChainId;
destinationChainId: ChainId;
size?: number;
arrowSize?: number;
isNarrow?: boolean;
}) {
const isMobile = useIsMobile();
if (isMobile) {
size = Math.floor(size * 0.8);
arrowSize = Math.floor(arrowSize * 0.8);
}
return (
<div
className={`flex items-center justify-center sm:space-x-1 ${isNarrow ? '' : 'md:space-x-2'}`}
>
<ChainLogo chainId={originChainId} size={size} />
<Image src={ArrowRightIcon} width={arrowSize} height={arrowSize} alt="" />
<ChainLogo chainId={destinationChainId} size={size} />
</div>
);
}
export const ChainToChain = memo(ChainToChain_);

@ -59,15 +59,12 @@ export function Header({ pathName }: { pathName: string }) {
<Link href="/" className={navLinkClass('/')}> <Link href="/" className={navLinkClass('/')}>
Home Home
</Link> </Link>
<Link href="/settings" className={navLinkClass('/settings')}>
Settings
</Link>
<Link href="/api-docs" className={navLinkClass('/api-docs')}>
API
</Link>
<a className={navLinkClass()} target="_blank" href={links.home} rel="noopener noreferrer"> <a className={navLinkClass()} target="_blank" href={links.home} rel="noopener noreferrer">
About About
</a> </a>
<Link href="/api-docs" className={navLinkClass('/api-docs')}>
API
</Link>
<a <a
className={navLinkClass()} className={navLinkClass()}
target="_blank" target="_blank"
@ -76,6 +73,9 @@ export function Header({ pathName }: { pathName: string }) {
> >
Docs Docs
</a> </a>
<Link href="/settings" className={navLinkClass('/settings')}>
Settings
</Link>
{showSearch && <MiniSearchBar />} {showSearch && <MiniSearchBar />}
</nav> </nav>
{/* Dropdown menu, used on mobile */} {/* Dropdown menu, used on mobile */}

@ -1,13 +1,13 @@
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { ChainMetadata, mainnetChainsMetadata, testnetChainsMetadata } from '@hyperlane-xyz/sdk'; import { ChainMetadata } from '@hyperlane-xyz/sdk';
import { arrayToObject } from '@hyperlane-xyz/utils'; import { arrayToObject } from '@hyperlane-xyz/utils';
import { getChainDisplayName } from '../../features/chains/utils'; import { getChainDisplayName } from '../../features/chains/utils';
import { useMultiProvider } from '../../features/providers/multiProvider';
import GearIcon from '../../images/icons/gear.svg'; import GearIcon from '../../images/icons/gear.svg';
import { useMultiProvider } from '../../store';
import { Color } from '../../styles/Color'; import { Color } from '../../styles/Color';
import { SolidButton } from '../buttons/SolidButton'; import { SolidButton } from '../buttons/SolidButton';
import { TextButton } from '../buttons/TextButton'; import { TextButton } from '../buttons/TextButton';
@ -17,8 +17,6 @@ import { CheckBox } from '../input/Checkbox';
import { DatetimeField } from '../input/DatetimeField'; import { DatetimeField } from '../input/DatetimeField';
import { DropdownModal } from '../layout/Dropdown'; import { DropdownModal } from '../layout/Dropdown';
const mainnetAndTestChains = [...mainnetChainsMetadata, ...testnetChainsMetadata];
interface Props { interface Props {
originChain: string | null; originChain: string | null;
onChangeOrigin: (value: string | null) => void; onChangeOrigin: (value: string | null) => void;
@ -85,12 +83,18 @@ function ChainMultiSelector({
position?: string; position?: string;
}) { }) {
const multiProvider = useMultiProvider(); const multiProvider = useMultiProvider();
const { chains, mainnets, testnets } = useMemo(() => {
const chains = Object.values(multiProvider.metadata);
const mainnets = chains.filter((c) => !c.isTestnet);
const testnets = chains.filter((c) => !!c.isTestnet);
return { chains, mainnets, testnets };
}, [multiProvider]);
// Need local state as buffer before user hits apply // Need local state as buffer before user hits apply
const [checkedChains, setCheckedChains] = useState( const [checkedChains, setCheckedChains] = useState(
value value
? arrayToObject(value.split(',')) ? arrayToObject(value.split(','))
: arrayToObject(mainnetAndTestChains.map((c) => c.chainId.toString())), : arrayToObject(chains.map((c) => c.chainId.toString())),
); );
const hasAnyUncheckedChain = (chains: ChainMetadata[]) => { const hasAnyUncheckedChain = (chains: ChainMetadata[]) => {
@ -102,7 +106,7 @@ function ChainMultiSelector({
const onToggle = (chainId: string | number) => { const onToggle = (chainId: string | number) => {
return (checked: boolean) => { return (checked: boolean) => {
if (!hasAnyUncheckedChain(mainnetAndTestChains)) { if (!hasAnyUncheckedChain(chains)) {
// If none are unchecked, uncheck all except this one // If none are unchecked, uncheck all except this one
setCheckedChains({ [chainId]: true }); setCheckedChains({ [chainId]: true });
} else { } else {
@ -125,7 +129,7 @@ function ChainMultiSelector({
}; };
const onToggleAll = () => { const onToggleAll = () => {
setCheckedChains(arrayToObject(mainnetAndTestChains.map((c) => c.chainId.toString()))); setCheckedChains(arrayToObject(chains.map((c) => c.chainId.toString())));
}; };
const onToggleNone = () => { const onToggleNone = () => {
@ -134,7 +138,7 @@ function ChainMultiSelector({
const onClickApply = (closeDropdown?: () => void) => { const onClickApply = (closeDropdown?: () => void) => {
const checkedList = Object.keys(checkedChains).filter((c) => !!checkedChains[c]); const checkedList = Object.keys(checkedChains).filter((c) => !!checkedChains[c]);
if (checkedList.length === 0 || checkedList.length === mainnetAndTestChains.length) { if (checkedList.length === 0 || checkedList.length === chains.length) {
// Use null value, indicating to filter needed // Use null value, indicating to filter needed
onChangeValue(null); onChangeValue(null);
} else { } else {
@ -175,14 +179,14 @@ function ChainMultiSelector({
<div className="flex flex-col"> <div className="flex flex-col">
<div className="pb-1.5"> <div className="pb-1.5">
<CheckBox <CheckBox
checked={!hasAnyUncheckedChain(mainnetChainsMetadata)} checked={!hasAnyUncheckedChain(mainnets)}
onToggle={onToggleSection(mainnetChainsMetadata)} onToggle={onToggleSection(mainnets)}
name="mainnet-chains" name="mainnet-chains"
> >
<h4 className="ml-2 text-gray-800">Mainnet Chains</h4> <h4 className="ml-2 text-gray-800">Mainnet Chains</h4>
</CheckBox> </CheckBox>
</div> </div>
{mainnetChainsMetadata.map((c) => ( {mainnets.map((c) => (
<CheckBox <CheckBox
key={c.name} key={c.name}
checked={!!checkedChains[c.chainId]} checked={!!checkedChains[c.chainId]}
@ -193,7 +197,7 @@ function ChainMultiSelector({
<span className="mr-2 font-light"> <span className="mr-2 font-light">
{getChainDisplayName(multiProvider, c.chainId, true)} {getChainDisplayName(multiProvider, c.chainId, true)}
</span> </span>
<ChainLogo chainId={c.chainId} size={12} color={false} background={false} /> <ChainLogo chainId={c.chainId} size={12} background={false} />
</div> </div>
</CheckBox> </CheckBox>
))} ))}
@ -202,14 +206,14 @@ function ChainMultiSelector({
<div className="flex flex-col"> <div className="flex flex-col">
<div className="pb-1.5"> <div className="pb-1.5">
<CheckBox <CheckBox
checked={!hasAnyUncheckedChain(testnetChainsMetadata)} checked={!hasAnyUncheckedChain(testnets)}
onToggle={onToggleSection(testnetChainsMetadata)} onToggle={onToggleSection(testnets)}
name="testnet-chains" name="testnet-chains"
> >
<h4 className="ml-2 text-gray-800">Testnet Chains</h4> <h4 className="ml-2 text-gray-800">Testnet Chains</h4>
</CheckBox> </CheckBox>
</div> </div>
{testnetChainsMetadata.map((c) => ( {testnets.map((c) => (
<CheckBox <CheckBox
key={c.name} key={c.name}
checked={!!checkedChains[c.chainId]} checked={!!checkedChains[c.chainId]}
@ -220,7 +224,7 @@ function ChainMultiSelector({
<span className="mr-2 font-light"> <span className="mr-2 font-light">
{getChainDisplayName(multiProvider, c.chainId, true)} {getChainDisplayName(multiProvider, c.chainId, true)}
</span> </span>
<ChainLogo chainId={c.chainId} size={12} color={false} background={false} /> <ChainLogo chainId={c.chainId} size={12} background={false} />
</div> </div>
</CheckBox> </CheckBox>
))} ))}

@ -8,6 +8,3 @@ export const ENVIRONMENT_BUCKET_SEGMENT: Record<Environment, string> = {
[Environment.Mainnet]: 'mainnet3', [Environment.Mainnet]: 'mainnet3',
[Environment.Testnet]: 'testnet4', [Environment.Testnet]: 'testnet4',
}; };
// TODO replace with SDK version
export const MAILBOX_VERSION = 3;

@ -18,4 +18,5 @@ export const docLinks = {
pi: 'https://v3.hyperlane.xyz/docs/deploy-hyperlane', pi: 'https://v3.hyperlane.xyz/docs/deploy-hyperlane',
ism: 'https://v3.hyperlane.xyz/docs/reference/ISM/specify-your-ISM', ism: 'https://v3.hyperlane.xyz/docs/reference/ISM/specify-your-ISM',
gas: 'https://v3.hyperlane.xyz/docs/protocol/interchain-gas-payment', gas: 'https://v3.hyperlane.xyz/docs/protocol/interchain-gas-payment',
registry: 'https://docs.hyperlane.xyz/docs/reference/registries',
}; };

@ -1,8 +1,6 @@
import { Client } from '@urql/core'; import { Client } from '@urql/core';
import type { NextApiRequest } from 'next'; import type { NextApiRequest } from 'next';
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { API_GRAPHQL_QUERY_LIMIT } from '../../consts/api'; import { API_GRAPHQL_QUERY_LIMIT } from '../../consts/api';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { sanitizeString } from '../../utils/string'; import { sanitizeString } from '../../utils/string';
@ -11,7 +9,7 @@ import { MessagesQueryResult } from '../messages/queries/fragments';
import { parseMessageQueryResult } from '../messages/queries/parse'; import { parseMessageQueryResult } from '../messages/queries/parse';
import { ApiHandlerResult, ApiMessage, toApiMessage } from './types'; import { ApiHandlerResult, ApiMessage, toApiMessage } from './types';
import { failureResult, successResult } from './utils'; import { failureResult, getMultiProvider, successResult } from './utils';
export async function handler( export async function handler(
req: NextApiRequest, req: NextApiRequest,
@ -27,7 +25,8 @@ export async function handler(
API_GRAPHQL_QUERY_LIMIT, API_GRAPHQL_QUERY_LIMIT,
); );
const result = await client.query<MessagesQueryResult>(query, variables).toPromise(); const result = await client.query<MessagesQueryResult>(query, variables).toPromise();
const multiProvider = new MultiProvider();
const multiProvider = await getMultiProvider();
const messages = parseMessageQueryResult(multiProvider, result.data); const messages = parseMessageQueryResult(multiProvider, result.data);
return successResult(messages.map(toApiMessage)); return successResult(messages.map(toApiMessage));
} }

@ -1,8 +1,6 @@
import { Client } from '@urql/core'; import { Client } from '@urql/core';
import type { NextApiRequest } from 'next'; import type { NextApiRequest } from 'next';
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { API_GRAPHQL_QUERY_LIMIT } from '../../consts/api'; import { API_GRAPHQL_QUERY_LIMIT } from '../../consts/api';
import { MessageStatus } from '../../types'; import { MessageStatus } from '../../types';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
@ -12,7 +10,7 @@ import { parseMessageStubResult } from '../messages/queries/parse';
import { parseQueryParams } from './getMessages'; import { parseQueryParams } from './getMessages';
import { ApiHandlerResult } from './types'; import { ApiHandlerResult } from './types';
import { failureResult, successResult } from './utils'; import { failureResult, getMultiProvider, successResult } from './utils';
interface MessageStatusResult { interface MessageStatusResult {
id: string; id: string;
@ -35,7 +33,7 @@ export async function handler(
); );
const result = await client.query<MessagesStubQueryResult>(query, variables).toPromise(); const result = await client.query<MessagesStubQueryResult>(query, variables).toPromise();
const multiProvider = new MultiProvider(); const multiProvider = await getMultiProvider();
const messages = parseMessageStubResult(multiProvider, result.data); const messages = parseMessageStubResult(multiProvider, result.data);
return successResult(messages.map((m) => ({ id: m.msgId, status: m.status }))); return successResult(messages.map((m) => ({ id: m.msgId, status: m.status })));

@ -1,8 +1,6 @@
import { Client } from '@urql/core'; import { Client } from '@urql/core';
import type { NextApiRequest } from 'next'; import type { NextApiRequest } from 'next';
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { API_GRAPHQL_QUERY_LIMIT } from '../../consts/api'; import { API_GRAPHQL_QUERY_LIMIT } from '../../consts/api';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { sanitizeString } from '../../utils/string'; import { sanitizeString } from '../../utils/string';
@ -11,7 +9,7 @@ import { MessagesQueryResult } from '../messages/queries/fragments';
import { parseMessageQueryResult } from '../messages/queries/parse'; import { parseMessageQueryResult } from '../messages/queries/parse';
import { ApiHandlerResult, ApiMessage, toApiMessage } from './types'; import { ApiHandlerResult, ApiMessage, toApiMessage } from './types';
import { failureResult, successResult } from './utils'; import { failureResult, getMultiProvider, successResult } from './utils';
const SEARCH_QUERY_PARAM_NAME = 'query'; const SEARCH_QUERY_PARAM_NAME = 'query';
@ -34,7 +32,7 @@ export async function handler(
); );
const result = await client.query<MessagesQueryResult>(query, variables).toPromise(); const result = await client.query<MessagesQueryResult>(query, variables).toPromise();
const multiProvider = new MultiProvider(); const multiProvider = await getMultiProvider();
const messages = parseMessageQueryResult(multiProvider, result.data); const messages = parseMessageQueryResult(multiProvider, result.data);
return successResult(messages.map(toApiMessage)); return successResult(messages.map(toApiMessage));

@ -1,3 +1,6 @@
import { GithubRegistry } from '@hyperlane-xyz/registry';
import { MultiProvider } from '@hyperlane-xyz/sdk';
export function successResult<R>(data: R): { success: true; data: R } { export function successResult<R>(data: R): { success: true; data: R } {
return { success: true, data }; return { success: true, data };
} }
@ -5,3 +8,9 @@ export function successResult<R>(data: R): { success: true; data: R } {
export function failureResult(error: string): { success: false; error: string } { export function failureResult(error: string): { success: false; error: string } {
return { success: false, error }; return { success: false, error };
} }
export async function getMultiProvider(): Promise<MultiProvider> {
const registry = new GithubRegistry();
const chainMetadata = await registry.getMetadata();
return new MultiProvider(chainMetadata);
}

@ -1,6 +1,6 @@
import { ChangeEventHandler, useState } from 'react'; import { ChangeEventHandler, useState } from 'react';
import { ChainName, mainnetChainsMetadata, testnetChainsMetadata } from '@hyperlane-xyz/sdk'; import { ChainName } from '@hyperlane-xyz/sdk';
import { CopyButton } from '../../components/buttons/CopyButton'; import { CopyButton } from '../../components/buttons/CopyButton';
import { SolidButton } from '../../components/buttons/SolidButton'; import { SolidButton } from '../../components/buttons/SolidButton';
@ -9,11 +9,10 @@ import { ChainLogo } from '../../components/icons/ChainLogo';
import { Card } from '../../components/layout/Card'; import { Card } from '../../components/layout/Card';
import { Modal } from '../../components/layout/Modal'; import { Modal } from '../../components/layout/Modal';
import { docLinks } from '../../consts/links'; import { docLinks } from '../../consts/links';
import { useMultiProvider } from '../providers/multiProvider'; import { useMultiProvider } from '../../store';
import { tryParseChainConfig } from './chainConfig'; import { tryParseChainConfig } from './chainConfig';
import { useChainConfigsRW } from './useChainConfigs'; import { useChainConfigsRW } from './useChainConfigs';
import { getChainDisplayName } from './utils';
export function ConfigureChains() { export function ConfigureChains() {
const { chainConfigs, setChainConfigs } = useChainConfigsRW(); const { chainConfigs, setChainConfigs } = useChainConfigsRW();
@ -70,33 +69,18 @@ export function ConfigureChains() {
</a> </a>
. This explorer can be configured to search for messages on any PI chain. . This explorer can be configured to search for messages on any PI chain.
</p> </p>
<h3 className="mt-6 text-lg text-blue-500 font-medium">Default Chains</h3> <p className="mt-3 font-light">
<div className="mt-4 flex"> To make you chain available to all users, add its metadata to the
<h4 className="text-gray-600 font-medium text-sm">Mainnets:</h4> <a
<div className="ml-3 flex gap-3.5 flex-wrap"> href={docLinks.registry}
{mainnetChainsMetadata.map((c) => ( target="_blank"
<div className="shrink-0 text-sm flex items-center" key={c.name}> rel="noopener noreferrer"
<ChainLogo chainId={c.chainId} size={15} color={true} background={false} /> className="underline underline-offset-2 text-blue-500 hover:text-blue-400"
<span className="ml-1.5 font-light"> >
{getChainDisplayName(multiProvider, c.chainId, true)} canonical Hyperlane Registry
</span> </a>
</div> . Or use the section below to add for just your own use.
))} </p>
</div>
</div>
<div className="mt-5 flex">
<h4 className="text-gray-600 font-medium text-sm">Testnets:</h4>
<div className="ml-3 flex gap-3.5 flex-wrap">
{testnetChainsMetadata.map((c) => (
<div className="shrink-0 text-sm flex items-center" key={c.name}>
<ChainLogo chainId={c.chainId} size={15} color={true} background={false} />
<div className="ml-1.5 font-light">
{getChainDisplayName(multiProvider, c.chainId, true)}
</div>
</div>
))}
</div>
</div>
<h3 className="mt-6 text-lg text-blue-500 font-medium">Custom Chains</h3> <h3 className="mt-6 text-lg text-blue-500 font-medium">Custom Chains</h3>
<table className="mt-2 w-full"> <table className="mt-2 w-full">
<thead> <thead>
@ -114,7 +98,7 @@ export function ConfigureChains() {
{Object.values(chainConfigs).map((chain) => ( {Object.values(chainConfigs).map((chain) => (
<tr key={`chain-${chain.chainId}`}> <tr key={`chain-${chain.chainId}`}>
<td> <td>
<ChainLogo chainId={chain.chainId} size={32} color={true} background={true} /> <ChainLogo chainId={chain.chainId} size={32} background={true} />
</td> </td>
<td className={styles.value}>{chain.chainId}</td> <td className={styles.value}>{chain.chainId}</td>
<td className={styles.value}>{chain.domainId || chain.chainId}</td> <td className={styles.value}>{chain.domainId || chain.chainId}</td>

@ -1,40 +1,40 @@
import { import { CoreChain, CoreChains, IRegistry } from '@hyperlane-xyz/registry';
ChainMap, import { ChainMap, MultiProvider } from '@hyperlane-xyz/sdk';
type MultiProvider,
chainIdToMetadata,
hyperlaneContractAddresses,
} from '@hyperlane-xyz/sdk';
import { toTitleCase } from '@hyperlane-xyz/utils'; import { toTitleCase } from '@hyperlane-xyz/utils';
import { Environment } from '../../consts/environments'; import { Environment } from '../../consts/environments';
import { ChainConfig } from './chainConfig'; import { ChainConfig } from './chainConfig';
export function getChainName(mp: MultiProvider, chainId?: number | string) { export async function getMailboxAddress(
return mp.tryGetChainName(chainId || 0) || undefined; chainName: string,
} customChainConfigs: ChainMap<ChainConfig>,
registry: IRegistry,
export function getMailboxAddress(customChainConfigs: ChainMap<ChainConfig>, chainName: string) { ) {
return customChainConfigs[chainName]?.mailbox ?? hyperlaneContractAddresses[chainName]?.mailbox; if (customChainConfigs[chainName]?.mailbox) return customChainConfigs[chainName].mailbox;
const addresses = await registry.getChainAddresses(chainName);
if (addresses?.mailbox) return addresses.mailbox;
else return undefined;
} }
export function getChainDisplayName( export function getChainDisplayName(
mp: MultiProvider, multiProvider: MultiProvider,
chainOrDomainId?: ChainId | DomainId, chainOrDomainId?: ChainId | DomainId,
shortName = false, shortName = false,
fallbackToId = true, fallbackToId = true,
) { ) {
const metadata = mp.tryGetChainMetadata(chainOrDomainId || 0); const metadata = multiProvider.tryGetChainMetadata(chainOrDomainId || 0);
if (!metadata) return fallbackToId && chainOrDomainId ? chainOrDomainId : 'Unknown'; if (!metadata) return fallbackToId && chainOrDomainId ? chainOrDomainId : 'Unknown';
const displayName = shortName ? metadata.displayNameShort : metadata.displayName; const displayName = shortName ? metadata.displayNameShort : metadata.displayName;
return toTitleCase(displayName || metadata.displayName || metadata.name); return toTitleCase(displayName || metadata.displayName || metadata.name);
} }
export function getChainEnvironment(mp: MultiProvider, chainIdOrName: number | string) { export function getChainEnvironment(multiProvider: MultiProvider, chainIdOrName: number | string) {
const isTestnet = mp.tryGetChainMetadata(chainIdOrName)?.isTestnet; const isTestnet = multiProvider.tryGetChainMetadata(chainIdOrName)?.isTestnet;
return isTestnet ? Environment.Testnet : Environment.Mainnet; return isTestnet ? Environment.Testnet : Environment.Mainnet;
} }
export function isPiChain(chainId: number | string) { export function isPiChain(multiProvider: MultiProvider, chainIdOrName: number | string) {
return !chainIdToMetadata[chainId]; const chainName = multiProvider.tryGetChainName(chainIdOrName);
return !chainName || !CoreChains.includes(chainName as CoreChain);
} }

@ -9,7 +9,8 @@ import {
IMultisigIsm__factory, IMultisigIsm__factory,
InterchainGasPaymaster__factory, InterchainGasPaymaster__factory,
} from '@hyperlane-xyz/core'; } from '@hyperlane-xyz/core';
import type { ChainMap, MultiProvider } from '@hyperlane-xyz/sdk'; import { IRegistry } from '@hyperlane-xyz/registry';
import { ChainMap, MAILBOX_VERSION, MultiProvider } from '@hyperlane-xyz/sdk';
import { import {
addressToBytes32, addressToBytes32,
errorToString, errorToString,
@ -19,7 +20,6 @@ import {
trimToLength, trimToLength,
} from '@hyperlane-xyz/utils'; } from '@hyperlane-xyz/utils';
import { MAILBOX_VERSION } from '../../consts/environments';
import { Message } from '../../types'; import { Message } from '../../types';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import type { ChainConfig } from '../chains/chainConfig'; import type { ChainConfig } from '../chains/chainConfig';
@ -34,6 +34,7 @@ const HANDLE_FUNCTION_SIG = 'handle(uint32,bytes32,bytes)';
export async function debugMessage( export async function debugMessage(
multiProvider: MultiProvider, multiProvider: MultiProvider,
registry: IRegistry,
customChainConfigs: ChainMap<ChainConfig>, customChainConfigs: ChainMap<ChainConfig>,
{ {
msgId, msgId,
@ -69,7 +70,7 @@ export async function debugMessage(
const recipInvalid = await isInvalidRecipient(destProvider, recipient); const recipInvalid = await isInvalidRecipient(destProvider, recipient);
if (recipInvalid) return recipInvalid; if (recipInvalid) return recipInvalid;
const destMailbox = getMailboxAddress(customChainConfigs, destName); const destMailbox = await getMailboxAddress(destName, customChainConfigs, registry);
if (!destMailbox) if (!destMailbox)
throw new Error(`Cannot debug message, no mailbox address provided for chain ${destName}`); throw new Error(`Cannot debug message, no mailbox address provided for chain ${destName}`);

@ -1,6 +1,7 @@
import { constants } from 'ethers'; import { constants } from 'ethers';
import { IMailbox__factory } from '@hyperlane-xyz/core'; import { IMailbox__factory } from '@hyperlane-xyz/core';
import { IRegistry } from '@hyperlane-xyz/registry';
import { ChainMap, MultiProvider } from '@hyperlane-xyz/sdk'; import { ChainMap, MultiProvider } from '@hyperlane-xyz/sdk';
import { Message, MessageStatus } from '../../types'; import { Message, MessageStatus } from '../../types';
@ -20,11 +21,12 @@ import {
export async function fetchDeliveryStatus( export async function fetchDeliveryStatus(
multiProvider: MultiProvider, multiProvider: MultiProvider,
registry: IRegistry,
customChainConfigs: ChainMap<ChainConfig>, customChainConfigs: ChainMap<ChainConfig>,
message: Message, message: Message,
): Promise<MessageDeliveryStatusResponse> { ): Promise<MessageDeliveryStatusResponse> {
const destName = multiProvider.getChainName(message.destinationChainId); const destName = multiProvider.getChainName(message.destinationChainId);
const destMailboxAddr = getMailboxAddress(customChainConfigs, destName); const destMailboxAddr = await getMailboxAddress(destName, customChainConfigs, registry);
if (!destMailboxAddr) if (!destMailboxAddr)
throw new Error( throw new Error(
`Cannot check delivery status, no mailbox address provided for chain ${destName}`, `Cannot check delivery status, no mailbox address provided for chain ${destName}`,
@ -65,7 +67,7 @@ export async function fetchDeliveryStatus(
}; };
return result; return result;
} else { } else {
const debugResult = await debugMessage(multiProvider, customChainConfigs, message); const debugResult = await debugMessage(multiProvider, registry, customChainConfigs, message);
const messageStatus = const messageStatus =
debugResult.status === MessageDebugStatus.NoErrorsFound debugResult.status === MessageDebugStatus.NoErrorsFound
? MessageStatus.Pending ? MessageStatus.Pending

@ -4,17 +4,18 @@ import { toast } from 'react-toastify';
import { errorToString } from '@hyperlane-xyz/utils'; import { errorToString } from '@hyperlane-xyz/utils';
import { useMultiProvider, useRegistry } from '../../store';
import { Message, MessageStatus } from '../../types'; import { Message, MessageStatus } from '../../types';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { MissingChainConfigToast } from '../chains/MissingChainConfigToast'; import { MissingChainConfigToast } from '../chains/MissingChainConfigToast';
import { useChainConfigs } from '../chains/useChainConfigs'; import { useChainConfigs } from '../chains/useChainConfigs';
import { useMultiProvider } from '../providers/multiProvider';
import { fetchDeliveryStatus } from './fetchDeliveryStatus'; import { fetchDeliveryStatus } from './fetchDeliveryStatus';
export function useMessageDeliveryStatus({ message, pause }: { message: Message; pause: boolean }) { export function useMessageDeliveryStatus({ message, pause }: { message: Message; pause: boolean }) {
const chainConfigs = useChainConfigs(); const chainConfigs = useChainConfigs();
const multiProvider = useMultiProvider(); const multiProvider = useMultiProvider();
const registry = useRegistry();
const serializedMessage = JSON.stringify(message); const serializedMessage = JSON.stringify(message);
const { data, error, isFetching } = useQuery( const { data, error, isFetching } = useQuery(
@ -41,7 +42,12 @@ export function useMessageDeliveryStatus({ message, pause }: { message: Message;
} }
logger.debug('Fetching message delivery status for:', message.id); logger.debug('Fetching message delivery status for:', message.id);
const deliverStatus = await fetchDeliveryStatus(multiProvider, chainConfigs, message); const deliverStatus = await fetchDeliveryStatus(
multiProvider,
registry,
chainConfigs,
message,
);
return deliverStatus; return deliverStatus;
}, },
{ retry: false }, { retry: false },

@ -7,12 +7,11 @@ import { toTitleCase, trimToLength } from '@hyperlane-xyz/utils';
import { Spinner } from '../../components/animations/Spinner'; import { Spinner } from '../../components/animations/Spinner';
import { Card } from '../../components/layout/Card'; import { Card } from '../../components/layout/Card';
import CheckmarkIcon from '../../images/icons/checkmark-circle.svg'; import CheckmarkIcon from '../../images/icons/checkmark-circle.svg';
import { useStore } from '../../store'; import { useMultiProvider, useStore } from '../../store';
import { Message, MessageStatus } from '../../types'; import { Message, MessageStatus } from '../../types';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { getChainDisplayName } from '../chains/utils'; import { getChainDisplayName } from '../chains/utils';
import { useMessageDeliveryStatus } from '../deliveryStatus/useMessageDeliveryStatus'; import { useMessageDeliveryStatus } from '../deliveryStatus/useMessageDeliveryStatus';
import { useMultiProvider } from '../providers/multiProvider';
import { ContentDetailsCard } from './cards/ContentDetailsCard'; import { ContentDetailsCard } from './cards/ContentDetailsCard';
import { GasDetailsCard } from './cards/GasDetailsCard'; import { GasDetailsCard } from './cards/GasDetailsCard';

@ -5,10 +5,10 @@ import { MultiProvider } from '@hyperlane-xyz/sdk';
import { shortenAddress } from '@hyperlane-xyz/utils'; import { shortenAddress } from '@hyperlane-xyz/utils';
import { ChainLogo } from '../../components/icons/ChainLogo'; import { ChainLogo } from '../../components/icons/ChainLogo';
import { useMultiProvider } from '../../store';
import { MessageStatus, MessageStub } from '../../types'; import { MessageStatus, MessageStub } from '../../types';
import { getHumanReadableDuration, getHumanReadableTimeString } from '../../utils/time'; import { getHumanReadableDuration, getHumanReadableTimeString } from '../../utils/time';
import { getChainDisplayName } from '../chains/utils'; import { getChainDisplayName } from '../chains/utils';
import { useMultiProvider } from '../providers/multiProvider';
import { serializeMessage } from './utils'; import { serializeMessage } from './utils';

@ -1,12 +1,12 @@
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { MAILBOX_VERSION } from '@hyperlane-xyz/sdk';
import { formatMessage } from '@hyperlane-xyz/utils'; import { formatMessage } from '@hyperlane-xyz/utils';
import { HelpIcon } from '../../../components/icons/HelpIcon'; import { HelpIcon } from '../../../components/icons/HelpIcon';
import { SelectField } from '../../../components/input/SelectField'; import { SelectField } from '../../../components/input/SelectField';
import { Card } from '../../../components/layout/Card'; import { Card } from '../../../components/layout/Card';
import { MAILBOX_VERSION } from '../../../consts/environments';
import EnvelopeInfo from '../../../images/icons/envelope-info.svg'; import EnvelopeInfo from '../../../images/icons/envelope-info.svg';
import { Message } from '../../../types'; import { Message } from '../../../types';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';

@ -10,11 +10,11 @@ import { HelpIcon } from '../../../components/icons/HelpIcon';
import { Card } from '../../../components/layout/Card'; import { Card } from '../../../components/layout/Card';
import { docLinks } from '../../../consts/links'; import { docLinks } from '../../../consts/links';
import FuelPump from '../../../images/icons/fuel-pump.svg'; import FuelPump from '../../../images/icons/fuel-pump.svg';
import { useMultiProvider } from '../../../store';
import { Message } from '../../../types'; import { Message } from '../../../types';
import { BigNumberMax } from '../../../utils/big-number'; import { BigNumberMax } from '../../../utils/big-number';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { GasPayment } from '../../debugger/types'; import { GasPayment } from '../../debugger/types';
import { useMultiProvider } from '../../providers/multiProvider';
import { KeyValueRow } from './KeyValueRow'; import { KeyValueRow } from './KeyValueRow';

@ -9,12 +9,12 @@ import { HelpIcon } from '../../../components/icons/HelpIcon';
import { Card } from '../../../components/layout/Card'; import { Card } from '../../../components/layout/Card';
import { Modal } from '../../../components/layout/Modal'; import { Modal } from '../../../components/layout/Modal';
import { links } from '../../../consts/links'; import { links } from '../../../consts/links';
import { useMultiProvider } from '../../../store';
import { MessageStatus, MessageTx } from '../../../types'; import { MessageStatus, MessageTx } from '../../../types';
import { getDateTimeString, getHumanReadableTimeString } from '../../../utils/time'; import { getDateTimeString, getHumanReadableTimeString } from '../../../utils/time';
import { getChainDisplayName } from '../../chains/utils'; import { getChainDisplayName } from '../../chains/utils';
import { debugStatusToDesc } from '../../debugger/strings'; import { debugStatusToDesc } from '../../debugger/strings';
import { MessageDebugResult } from '../../debugger/types'; import { MessageDebugResult } from '../../debugger/types';
import { useMultiProvider } from '../../providers/multiProvider';
import { LabelAndCodeBlock } from './CodeBlock'; import { LabelAndCodeBlock } from './CodeBlock';
import { KeyValueRow } from './KeyValueRow'; import { KeyValueRow } from './KeyValueRow';

@ -5,8 +5,8 @@ import { useMemo } from 'react';
import { InterchainAccountRouter__factory } from '@hyperlane-xyz/core'; import { InterchainAccountRouter__factory } from '@hyperlane-xyz/core';
import { eqAddress, isValidAddress } from '@hyperlane-xyz/utils'; import { eqAddress, isValidAddress } from '@hyperlane-xyz/utils';
import { useMultiProvider } from '../../store';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { useMultiProvider } from '../providers/multiProvider';
// This assumes all chains have the same ICA address // This assumes all chains have the same ICA address
// const ICA_ADDRESS = hyperlaneEnvironments.mainnet.ethereum.interchainAccountRouter; // const ICA_ADDRESS = hyperlaneEnvironments.mainnet.ethereum.interchainAccountRouter;

@ -1,4 +1,5 @@
import { MultiProvider, chainMetadata, hyperlaneEnvironments } from '@hyperlane-xyz/sdk'; import { chainAddresses, chainMetadata } from '@hyperlane-xyz/registry';
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { Message, MessageStatus } from '../../../types'; import { Message, MessageStatus } from '../../../types';
import { ChainConfig } from '../../chains/chainConfig'; import { ChainConfig } from '../../chains/chainConfig';
@ -11,8 +12,8 @@ import { fetchMessagesFromPiChain } from './fetchPiChainMessages';
jest.setTimeout(30000); jest.setTimeout(30000);
const sepoliaMailbox = hyperlaneEnvironments.testnet.sepolia.mailbox; const sepoliaMailbox = chainAddresses.sepolia.mailbox;
const sepoliaIgp = hyperlaneEnvironments.testnet.sepolia.interchainGasPaymaster; const sepoliaIgp = chainAddresses.sepolia.interchainGasPaymaster;
const sepoliaConfigWithExplorer: ChainConfig = { const sepoliaConfigWithExplorer: ChainConfig = {
...chainMetadata.sepolia, ...chainMetadata.sepolia,
mailbox: sepoliaMailbox, mailbox: sepoliaMailbox,

@ -3,11 +3,11 @@ import { useQuery } from '@tanstack/react-query';
import { MultiProvider } from '@hyperlane-xyz/sdk'; import { MultiProvider } from '@hyperlane-xyz/sdk';
import { ensure0x } from '@hyperlane-xyz/utils'; import { ensure0x } from '@hyperlane-xyz/utils';
import { useMultiProvider } from '../../../store';
import { Message } from '../../../types'; import { Message } from '../../../types';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { ChainConfig } from '../../chains/chainConfig'; import { ChainConfig } from '../../chains/chainConfig';
import { useChainConfigs } from '../../chains/useChainConfigs'; import { useChainConfigs } from '../../chains/useChainConfigs';
import { useMultiProvider } from '../../providers/multiProvider';
import { isValidSearchQuery } from '../queries/useMessageQuery'; import { isValidSearchQuery } from '../queries/useMessageQuery';
import { PiMessageQuery, PiQueryType, fetchMessagesFromPiChain } from './fetchPiChainMessages'; import { PiMessageQuery, PiQueryType, fetchMessagesFromPiChain } from './fetchPiChainMessages';

@ -53,7 +53,8 @@ function parseMessageStub(multiProvider: MultiProvider, m: MessageStubEntry): Me
logger.warn(`No chainId known for domain ${destinationDomainId}. Using domain as chainId`); logger.warn(`No chainId known for domain ${destinationDomainId}. Using domain as chainId`);
destinationChainId = destinationDomainId; destinationChainId = destinationDomainId;
} }
const isPiMsg = isPiChain(m.origin_chain_id) || isPiChain(destinationChainId); const isPiMsg =
isPiChain(multiProvider, m.origin_chain_id) || isPiChain(multiProvider, destinationChainId);
return { return {
status: getMessageStatus(m), status: getMessageStatus(m),

@ -3,9 +3,9 @@ import { useQuery } from 'urql';
import { isAddressEvm, isValidTransactionHashEvm } from '@hyperlane-xyz/utils'; import { isAddressEvm, isValidTransactionHashEvm } from '@hyperlane-xyz/utils';
import { useMultiProvider } from '../../../store';
import { MessageStatus } from '../../../types'; import { MessageStatus } from '../../../types';
import { useInterval } from '../../../utils/useInterval'; import { useInterval } from '../../../utils/useInterval';
import { useMultiProvider } from '../../providers/multiProvider';
import { import {
MessageIdentifierType, MessageIdentifierType,
buildMessageQuery, buildMessageQuery,

@ -1,5 +0,0 @@
import { useStore } from '../../store';
export function useMultiProvider() {
return useStore((s) => s.multiProvider);
}

@ -5,6 +5,7 @@ import NextCors from 'nextjs-cors';
import { MultiProvider } from '@hyperlane-xyz/sdk'; import { MultiProvider } from '@hyperlane-xyz/sdk';
import { ENVIRONMENT_BUCKET_SEGMENT } from '../../consts/environments'; import { ENVIRONMENT_BUCKET_SEGMENT } from '../../consts/environments';
import { getMultiProvider } from '../../features/api/utils';
import { getChainEnvironment, isPiChain } from '../../features/chains/utils'; import { getChainEnvironment, isPiChain } from '../../features/chains/utils';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { fetchWithTimeout } from '../../utils/timeout'; import { fetchWithTimeout } from '../../utils/timeout';
@ -21,9 +22,9 @@ export default async function handler(
try { try {
const body = req.body as { chainId: ChainId }; const body = req.body as { chainId: ChainId };
if (!body.chainId) throw new Error('No chainId in body'); if (!body.chainId) throw new Error('No chainId in body');
const multiProvider = await getMultiProvider();
// TODO PI support here // TODO PI support here
if (isPiChain(body.chainId)) throw new Error('ChainId is unsupported'); if (isPiChain(multiProvider, body.chainId)) throw new Error('Only core chains are unsupported');
const multiProvider = new MultiProvider();
const nonce = await fetchLatestNonce(multiProvider, body.chainId); const nonce = await fetchLatestNonce(multiProvider, body.chainId);
res.status(200).json({ nonce }); res.status(200).json({ nonce });
} catch (error) { } catch (error) {

@ -1,7 +1,8 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { ChainMap, MultiProvider, chainMetadata } from '@hyperlane-xyz/sdk'; import { GithubRegistry, IRegistry } from '@hyperlane-xyz/registry';
import { ChainMap, MultiProvider } from '@hyperlane-xyz/sdk';
import { ChainConfig } from './features/chains/chainConfig'; import { ChainConfig } from './features/chains/chainConfig';
import { logger } from './utils/logger'; import { logger } from './utils/logger';
@ -16,20 +17,27 @@ interface AppState {
setChainConfigs: (configs: ChainMap<ChainConfig>) => void; setChainConfigs: (configs: ChainMap<ChainConfig>) => void;
multiProvider: MultiProvider; multiProvider: MultiProvider;
setMultiProvider: (mp: MultiProvider) => void; setMultiProvider: (mp: MultiProvider) => void;
registry: IRegistry;
setRegistry: (registry: IRegistry) => void;
bannerClassName: string; bannerClassName: string;
setBanner: (className: string) => void; setBanner: (className: string) => void;
} }
export const useStore = create<AppState>()( export const useStore = create<AppState>()(
persist( persist(
(set) => ({ (set, get) => ({
chainConfigs: {}, chainConfigs: {},
setChainConfigs: (configs: ChainMap<ChainConfig>) => { setChainConfigs: async (configs: ChainMap<ChainConfig>) => {
set({ chainConfigs: configs, multiProvider: buildMultiProvider(configs) }); const multiProvider = await buildMultiProvider(get().registry, configs);
set({ chainConfigs: configs, multiProvider });
}, },
multiProvider: buildMultiProvider({}), multiProvider: new MultiProvider({}),
setMultiProvider: (mp: MultiProvider) => { setMultiProvider: (multiProvider: MultiProvider) => {
set({ multiProvider: mp }); set({ multiProvider });
},
registry: new GithubRegistry(),
setRegistry: (registry: IRegistry) => {
set({ registry });
}, },
bannerClassName: '', bannerClassName: '',
setBanner: (className: string) => set({ bannerClassName: className }), setBanner: (className: string) => set({ bannerClassName: className }),
@ -45,14 +53,24 @@ export const useStore = create<AppState>()(
logger.error('Error during hydration', error); logger.error('Error during hydration', error);
return; return;
} }
state.setMultiProvider(buildMultiProvider(state.chainConfigs)); buildMultiProvider(state.registry, state.chainConfigs)
logger.debug('Hydration finished'); .then((mp) => state.setMultiProvider(mp))
.catch((e) => logger.error('Error building MultiProvider', e));
}; };
}, },
}, },
), ),
); );
function buildMultiProvider(customChainConfigs: ChainMap<ChainConfig>) { export function useMultiProvider() {
return new MultiProvider({ ...chainMetadata, ...customChainConfigs }); return useStore((s) => s.multiProvider);
}
export function useRegistry() {
return useStore((s) => s.registry);
}
async function buildMultiProvider(registry: IRegistry, customChainConfigs: ChainMap<ChainConfig>) {
const registryChainMetadata = await registry.getMetadata();
return new MultiProvider({ ...registryChainMetadata, ...customChainConfigs });
} }

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save