Merge pull request #34 from hyperlane-xyz/pi-ux-improvements

- Update to SDK 1.3.2
- Add support for chain metadata in the URL
- Add support for PI message search on message page
- Improve missing chain metadata error
- Improve chain data input UX
pull/37/head
J M Rossy 2 years ago committed by GitHub
commit 6ac49ed609
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      package.json
  2. 4
      src/components/icons/ChainLogo.tsx
  3. 4
      src/components/icons/ChainToChain.tsx
  4. 7
      src/components/nav/EnvironmentSelector.tsx
  5. 11
      src/components/search/SearchFilterBar.tsx
  6. 4
      src/features/api/searchPiMessages.ts
  7. 45
      src/features/chains/ConfigureChains.tsx
  8. 12
      src/features/chains/MissingChainConfigToast.tsx
  9. 19
      src/features/chains/chainConfig.ts
  10. 63
      src/features/chains/useChainConfigs.ts
  11. 15
      src/features/chains/utils.ts
  12. 4
      src/features/debugger/TxDebugger.tsx
  13. 47
      src/features/debugger/debugMessage.ts
  14. 11
      src/features/deliveryStatus/fetchDeliveryStatus.ts
  15. 25
      src/features/deliveryStatus/useMessageDeliveryStatus.tsx
  16. 92
      src/features/messages/MessageDetails.tsx
  17. 31
      src/features/messages/MessageSearch.tsx
  18. 13
      src/features/messages/MessageTable.tsx
  19. 11
      src/features/messages/cards/TransactionCard.tsx
  20. 16
      src/features/messages/ica.ts
  21. 6
      src/features/messages/pi-queries/fetchPiChainMessages.test.ts
  22. 172
      src/features/messages/pi-queries/fetchPiChainMessages.ts
  23. 120
      src/features/messages/pi-queries/usePiChainMessageQuery.ts
  24. 53
      src/features/messages/queries/useMessageQuery.ts
  25. 19
      src/features/messages/utils.ts
  26. 2
      src/global.d.ts
  27. 27
      src/multiProvider.ts
  28. 7
      src/pages/api-docs.tsx
  29. 17
      src/pages/api/latest-nonce.ts
  30. 4
      src/pages/debugger.tsx
  31. 4
      src/pages/index.tsx
  32. 9
      src/pages/message/[messageId].tsx
  33. 4
      src/pages/settings.tsx
  34. 35
      src/store.ts
  35. 8
      src/types.ts
  36. 22
      src/utils/base64.ts
  37. 14
      src/utils/explorers.ts
  38. 10
      src/utils/queryParams.ts
  39. 69
      yarn.lock

@ -5,8 +5,8 @@
"author": "J M Rossy",
"dependencies": {
"@headlessui/react": "^1.7.11",
"@hyperlane-xyz/sdk": "1.2.3",
"@hyperlane-xyz/widgets": "1.2.3",
"@hyperlane-xyz/sdk": "1.3.2",
"@hyperlane-xyz/widgets": "1.3.2",
"@metamask/jazzicon": "https://github.com/jmrossy/jazzicon#7a8df28974b4e81129bfbe3cab76308b889032a6",
"@rainbow-me/rainbowkit": "^0.11.0",
"@tanstack/react-query": "^4.24.10",

@ -3,9 +3,11 @@ import { ComponentProps } from 'react';
import { ChainLogo as ChainLogoInner } from '@hyperlane-xyz/widgets';
import { getChainName } from '../../features/chains/utils';
import { useMultiProvider } from '../../multiProvider';
export function ChainLogo(props: ComponentProps<typeof ChainLogoInner>) {
const { chainName, ...rest } = props;
const name = chainName || getChainName(props.chainId);
const multiProvider = useMultiProvider();
const name = chainName || getChainName(multiProvider, props.chainId);
return <ChainLogoInner {...rest} chainName={name} />;
}

@ -13,8 +13,8 @@ function _ChainToChain({
arrowSize = 32,
isNarrow = false,
}: {
originChainId: number;
destinationChainId: number;
originChainId: ChainId;
destinationChainId: ChainId;
size?: number;
arrowSize?: number;
isNarrow?: boolean;

@ -1,13 +1,16 @@
import { useEffect } from 'react';
import { Environment, environments } from '../../consts/environments';
import { useEnvironment } from '../../store';
import { useStore } from '../../store';
import { replacePathParam, useQueryParam } from '../../utils/queryParams';
import { toTitleCase } from '../../utils/string';
import { SelectField } from '../input/SelectField';
export function EnvironmentSelector() {
const { environment, setEnvironment } = useEnvironment();
const { environment, setEnvironment } = useStore((s) => ({
environment: s.environment,
setEnvironment: s.setEnvironment,
}));
const queryEnv = useQueryParam('env');
useEffect(() => {

@ -6,6 +6,7 @@ import { ChainMetadata, mainnetChainsMetadata, testnetChainsMetadata } from '@hy
import { getChainDisplayName } from '../../features/chains/utils';
import GearIcon from '../../images/icons/gear.svg';
import { useMultiProvider } from '../../multiProvider';
import { arrayToObject } from '../../utils/objects';
import { BorderedButton } from '../buttons/BorderedButton';
import { TextButton } from '../buttons/TextButton';
@ -87,6 +88,8 @@ function ChainMultiSelector({
onChangeValue: (value: string | null) => void;
position?: string;
}) {
const multiProvider = useMultiProvider();
// Need local state as buffer before user hits apply
const [checkedChains, setCheckedChains] = useState(
value
@ -188,7 +191,9 @@ function ChainMultiSelector({
name={c.name}
>
<div className="py-0.5 ml-2 text-sm flex items-center">
<span className="mr-2">{getChainDisplayName(c.chainId, true)}</span>
<span className="mr-2">
{getChainDisplayName(multiProvider, c.chainId, true)}
</span>
<ChainLogo chainId={c.chainId} size={12} color={false} background={false} />
</div>
</CheckBox>
@ -213,7 +218,9 @@ function ChainMultiSelector({
name={c.name}
>
<div className="py-0.5 ml-2 text-sm flex items-center">
<span className="mr-2">{getChainDisplayName(c.chainId, true)}</span>
<span className="mr-2">
{getChainDisplayName(multiProvider, c.chainId, true)}
</span>
<ChainLogo chainId={c.chainId} size={12} color={false} background={false} />
</div>
</CheckBox>

@ -8,7 +8,7 @@ import { tryParseChainConfig } from '../chains/chainConfig';
import {
PiMessageQuery,
fetchMessagesFromPiChain,
} from '../messages/queries/usePiChainMessageQuery';
} from '../messages/pi-queries/fetchPiChainMessages';
import { ApiHandlerResult, ApiMessage } from './types';
import { failureResult, successResult } from './utils';
@ -25,9 +25,7 @@ export async function handler(req: NextApiRequest): Promise<ApiHandlerResult<Api
const parseResult = tryParseChainConfig(req.body);
if (!parseResult.success) return failureResult(`Invalid chain configs: ${parseResult.error}`);
const chainConfig = parseResult.chainConfig;
if (!Object.values(chainConfig).length) return failureResult('No chain configs provided');
try {
logger.debug('Attempting to search for PI messages:', query);

@ -1,20 +1,23 @@
import { ChangeEventHandler, useState } from 'react';
import { mainnetChainsMetadata, testnetChainsMetadata } from '@hyperlane-xyz/sdk';
import { ChainName, mainnetChainsMetadata, testnetChainsMetadata } from '@hyperlane-xyz/sdk';
import { CopyButton } from '../../components/buttons/CopyButton';
import { SolidButton } from '../../components/buttons/SolidButton';
import { XIconButton } from '../../components/buttons/XIconButton';
import { ChainLogo } from '../../components/icons/ChainLogo';
import { Card } from '../../components/layout/Card';
import { Modal } from '../../components/layout/Modal';
import { links } from '../../consts/links';
import { useChainConfigs } from '../../store';
import { useMultiProvider } from '../../multiProvider';
import { tryParseChainConfig } from './chainConfig';
import { useChainConfigs } from './useChainConfigs';
import { getChainDisplayName } from './utils';
export function ConfigureChains() {
const { chainConfigs, setChainConfigs } = useChainConfigs();
const multiProvider = useMultiProvider();
const [showAddChainModal, setShowAddChainModal] = useState(false);
@ -31,11 +34,11 @@ export function ConfigureChains() {
const onClickAddChain = () => {
setChainInputErr('');
const result = tryParseChainConfig(customChainInput);
const result = tryParseChainConfig(customChainInput, multiProvider);
if (result.success) {
setChainConfigs({
...chainConfigs,
[result.chainConfig.chainId]: result.chainConfig,
[result.chainConfig.name]: result.chainConfig,
});
setCustomChainInput('');
setShowAddChainModal(false);
@ -44,9 +47,9 @@ export function ConfigureChains() {
}
};
const onClickRemoveChain = (chainId: number) => {
const onClickRemoveChain = (chainName: ChainName) => {
const newChainConfigs = { ...chainConfigs };
delete newChainConfigs[chainId];
delete newChainConfigs[chainName];
setChainConfigs({
...newChainConfigs,
});
@ -74,7 +77,7 @@ export function ConfigureChains() {
{mainnetChainsMetadata.map((c) => (
<div className="shrink-0 text-sm flex items-center" key={c.name}>
<ChainLogo chainId={c.chainId} size={15} color={true} background={false} />
<span className="ml-1.5">{getChainDisplayName(c.chainId, true)}</span>
<span className="ml-1.5">{getChainDisplayName(multiProvider, c.chainId, true)}</span>
</div>
))}
</div>
@ -85,7 +88,7 @@ export function ConfigureChains() {
{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">{getChainDisplayName(c.chainId, true)}</div>
<div className="ml-1.5">{getChainDisplayName(multiProvider, c.chainId, true)}</div>
</div>
))}
</div>
@ -120,7 +123,7 @@ export function ConfigureChains() {
</td>
<td>
<XIconButton
onClick={() => onClickRemoveChain(chain.chainId)}
onClick={() => onClickRemoveChain(chain.name)}
title="Remove"
size={22}
/>
@ -151,12 +154,20 @@ export function ConfigureChains() {
</a>{' '}
for examples.
</p>
<textarea
className="mt-4 w-full min-h-[20rem] p-2 border border-gray-400 rounded text-sm focus:outline-none"
placeholder={customChainTextareaPlaceholder}
value={customChainInput}
onChange={onCustomChainInputChange}
></textarea>
<div className="relative mt-4">
<textarea
className="w-full min-h-[20rem] p-2 border border-gray-400 rounded text-sm focus:outline-none"
placeholder={customChainTextareaPlaceholder}
value={customChainInput}
onChange={onCustomChainInputChange}
></textarea>
<CopyButton
copyValue={customChainInput || customChainTextareaPlaceholder}
width={16}
height={16}
classes="absolute top-2 right-2"
/>
</div>
{chainInputErr && <div className="mt-2 text-red-600 text-sm">{chainInputErr}</div>}
<SolidButton classes="mt-2 mb-2 py-0.5 w-full" onClick={onClickAddChain}>
Add
@ -178,7 +189,9 @@ const customChainTextareaPlaceholder = `{
} ],
"blocks": { "confirmations": 1, "estimateBlockTime": 13 },
"contracts": {
"mailbox": "0x123..."
"mailbox": "0x123...",
"interchainSecurityModule": "0x123...",
"interchainGasPaymaster": "0x123..."
}
}
`;

@ -0,0 +1,12 @@
import Link from 'next/link';
export function MissingChainConfigToast({ chainId }: { chainId: number }) {
return (
<div>
<span>No chain config found for chain ID: {chainId}. </span>
<Link href="/settings" className="underline">
Add a config
</Link>
</div>
);
}

@ -1,14 +1,13 @@
import { z } from 'zod';
import { ChainMetadata, ChainMetadataSchema } from '@hyperlane-xyz/sdk';
import { ChainMetadata, ChainMetadataSchema, MultiProvider } from '@hyperlane-xyz/sdk';
import { getMultiProvider } from '../../multiProvider';
import { logger } from '../../utils/logger';
export const chainContractsSchema = z.object({
mailbox: z.string(),
multisigIsm: z.string().optional(),
// interchainGasPaymaster: z.string().optional(),
interchainSecurityModule: z.string().optional(),
interchainGasPaymaster: z.string().optional(),
// interchainAccountRouter: z.string().optional(),
});
@ -27,7 +26,7 @@ type ParseResult =
error: string;
};
export function tryParseChainConfig(input: string): ParseResult {
export function tryParseChainConfig(input: string, mp?: MultiProvider): ParseResult {
let data: any;
try {
data = JSON.parse(input);
@ -52,7 +51,7 @@ export function tryParseChainConfig(input: string): ParseResult {
const chainConfig = result.data as ChainConfig;
// Reject blocksout explorers for now
// Reject blockscout explorers for now
if (chainConfig.blockExplorers?.[0]?.url.includes('blockscout')) {
return {
success: false,
@ -60,11 +59,11 @@ export function tryParseChainConfig(input: string): ParseResult {
};
}
const mp = getMultiProvider();
if (
mp.tryGetChainMetadata(chainConfig.name) ||
mp.tryGetChainMetadata(chainConfig.chainId) ||
(chainConfig.domainId && mp.tryGetChainMetadata(chainConfig.domainId))
mp &&
(mp.tryGetChainMetadata(chainConfig.name) ||
mp.tryGetChainMetadata(chainConfig.chainId) ||
(chainConfig.domainId && mp.tryGetChainMetadata(chainConfig.domainId)))
) {
return {
success: false,

@ -0,0 +1,63 @@
import { useEffect } from 'react';
import { z } from 'zod';
import { ChainMap, ChainMetadata, ChainMetadataSchema, objMerge } from '@hyperlane-xyz/sdk';
import { useStore } from '../../store';
import { fromBase64 } from '../../utils/base64';
import { logger } from '../../utils/logger';
import { useQueryParam } from '../../utils/queryParams';
import { ChainConfig } from './chainConfig';
const CHAIN_CONFIGS_KEY = 'chains';
const ChainMetadataArraySchema = z.array(ChainMetadataSchema);
// Use the chainConfigs from the store
export function useChainConfigs() {
return useStore((s) => ({
chainConfigs: s.chainConfigsV2,
setChainConfigs: s.setChainConfigs,
}));
}
// Use the chainConfigs from the store but with any
// chainConfigs from the query string merged in
export function useChainConfigsWithQueryParams() {
const { chainConfigs: storeConfigs, setChainConfigs } = useChainConfigs();
const queryVal = useQueryParam(CHAIN_CONFIGS_KEY);
useEffect(() => {
if (!queryVal) return;
const decoded = fromBase64<ChainMetadata[]>(queryVal);
if (!decoded) {
logger.error('Unable to decode chain configs in query string');
return;
}
const result = ChainMetadataArraySchema.safeParse(decoded);
if (!result.success) {
logger.error('Invalid chain configs in query string', result.error);
return;
}
const chainMetadataList = result.data as ChainMetadata[];
// Stop here if there are no new configs to save, otherwise the effect will loop
if (!chainMetadataList.length || chainMetadataList.every((c) => !!storeConfigs[c.name])) return;
const nameToChainConfig = chainMetadataList.reduce<ChainMap<ChainConfig>>(
(acc, chainMetadata) => {
// TODO would be great if we could get contract addrs here too
// But would require apps like warp template to get that from devs
acc[chainMetadata.name] = { ...chainMetadata, contracts: { mailbox: '' } };
return acc;
},
{},
);
const mergedConfig = objMerge(nameToChainConfig, storeConfigs) as ChainMap<ChainConfig>;
setChainConfigs(mergedConfig);
}, [storeConfigs, setChainConfigs, queryVal]);
return storeConfigs;
}

@ -1,19 +1,20 @@
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { Environment } from '../../consts/environments';
import { getMultiProvider } from '../../multiProvider';
import { toTitleCase } from '../../utils/string';
export function getChainName(chainId?: number) {
return getMultiProvider().tryGetChainName(chainId || 0) || undefined;
export function getChainName(mp: MultiProvider, chainId?: number) {
return mp.tryGetChainName(chainId || 0) || undefined;
}
export function getChainDisplayName(chainId?: number, shortName = false) {
const metadata = getMultiProvider().tryGetChainMetadata(chainId || 0);
export function getChainDisplayName(mp: MultiProvider, chainId?: number, shortName = false) {
const metadata = mp.tryGetChainMetadata(chainId || 0);
if (!metadata) return 'Unknown';
const displayName = shortName ? metadata.displayNameShort : metadata.displayName;
return toTitleCase(displayName || metadata.displayName || metadata.name);
}
export function getChainEnvironment(chainIdOrName: number | string) {
const isTestnet = getMultiProvider().tryGetChainMetadata(chainIdOrName)?.isTestnet;
export function getChainEnvironment(mp: MultiProvider, chainIdOrName: number | string) {
const isTestnet = mp.tryGetChainMetadata(chainIdOrName)?.isTestnet;
return isTestnet ? Environment.Testnet : Environment.Mainnet;
}

@ -12,6 +12,7 @@ import {
SearchUnknownError,
} from '../../components/search/SearchStates';
import ShrugIcon from '../../images/icons/shrug.svg';
import { useMultiProvider } from '../../multiProvider';
import { useStore } from '../../store';
import useDebounce from '../../utils/debounce';
import { replacePathParam, useQueryParam } from '../../utils/queryParams';
@ -26,6 +27,7 @@ const QUERY_HASH_PARAM = 'txHash';
export function TxDebugger() {
const environment = useStore((s) => s.environment);
const multiProvider = useMultiProvider();
const txHash = useQueryParam(QUERY_HASH_PARAM);
@ -48,7 +50,7 @@ export function TxDebugger() {
return null;
}
replacePathParam(QUERY_HASH_PARAM, sanitizedInput);
return debugMessagesForHash(sanitizedInput, environment);
return debugMessagesForHash(sanitizedInput, environment, multiProvider);
},
{ retry: false },
);

@ -3,8 +3,8 @@
import { BigNumber, providers } from 'ethers';
import {
type IInterchainGasPaymaster,
IMessageRecipient__factory,
type InterchainGasPaymaster,
type Mailbox,
} from '@hyperlane-xyz/core';
import {
@ -12,13 +12,13 @@ import {
CoreChainName,
DispatchedMessage,
HyperlaneCore,
HyperlaneIgp,
MultiProvider,
TestChains,
} from '@hyperlane-xyz/sdk';
import { utils } from '@hyperlane-xyz/utils';
import { Environment } from '../../consts/environments';
import { getMultiProvider } from '../../multiProvider';
import { Message } from '../../types';
import { trimLeading0x } from '../../utils/addresses';
import { errorToString } from '../../utils/errors';
@ -40,7 +40,7 @@ const HANDLE_FUNCTION_SIG = 'handle(uint32,bytes32,bytes)';
export async function debugMessagesForHash(
txHash: string,
environment: Environment,
multiProvider = getMultiProvider(),
multiProvider: MultiProvider,
): Promise<MessageDebugResult> {
const txDetails = await findTransactionDetails(txHash, multiProvider);
if (!txDetails?.transactionReceipt) {
@ -58,7 +58,7 @@ export async function debugMessagesForTransaction(
chainName: ChainName,
txReceipt: providers.TransactionReceipt,
environment: Environment,
multiProvider = getMultiProvider(),
multiProvider: MultiProvider,
nonce?: number,
): Promise<MessageDebugResult> {
// TODO PI support here
@ -89,7 +89,7 @@ export async function debugMessagesForTransaction(
continue;
}
logger.debug(`Checking message ${i + 1} of ${dispatchedMessages.length}`);
messageDetails.push(await debugDispatchedMessage(core, multiProvider, msg));
messageDetails.push(await debugDispatchedMessage(environment, core, multiProvider, msg));
logger.debug(`Done checking message ${i + 1}`);
}
return {
@ -101,6 +101,7 @@ export async function debugMessagesForTransaction(
}
async function debugDispatchedMessage(
environment: Environment,
core: HyperlaneCore,
multiProvider: MultiProvider,
message: DispatchedMessage,
@ -159,7 +160,13 @@ async function debugDispatchedMessage(
if (deliveryResult.status && deliveryResult.details) return { ...deliveryResult, properties };
const gasEstimate = deliveryResult.gasEstimate;
const insufficientGas = await isIgpUnderfunded(core, messageId, originName, gasEstimate);
const insufficientGas = await isIgpUnderfunded(
environment,
multiProvider,
messageId,
originName,
gasEstimate,
);
if (insufficientGas) return { ...insufficientGas, properties };
return noErrorFound(properties);
@ -167,7 +174,7 @@ async function debugDispatchedMessage(
export async function debugExplorerMessage(
message: Message,
multiProvider = getMultiProvider(),
multiProvider: MultiProvider,
): Promise<Omit<MessageDebugDetails, 'properties'>> {
const {
msgId,
@ -182,7 +189,7 @@ export async function debugExplorerMessage(
const originName = multiProvider.getChainName(originDomain);
const destName = multiProvider.tryGetChainName(destDomain)!;
const environment = getChainEnvironment(originName);
const environment = getChainEnvironment(multiProvider, originName);
// TODO PI support here
const core = HyperlaneCore.fromEnvironment(environment, multiProvider);
@ -208,7 +215,8 @@ export async function debugExplorerMessage(
const gasEstimate = deliveryResult.gasEstimate;
const insufficientGas = await isIgpUnderfunded(
core,
environment,
multiProvider,
msgId,
originName,
gasEstimate,
@ -255,7 +263,7 @@ async function fetchTransactionDetails(
}
}
function isInvalidDestDomain(core: HyperlaneCore, destDomain: number, destName: string | null) {
function isInvalidDestDomain(core: HyperlaneCore, destDomain: DomainId, destName: string | null) {
logger.debug(`Destination chain: ${destName}`);
if (!destName) {
logger.info(`Unknown destination domain ${destDomain}`);
@ -281,7 +289,7 @@ async function isMessageAlreadyDelivered(
messageId: string,
properties: MessageDebugDetails['properties'],
) {
const destMailbox = core.getContracts(destName).mailbox.contract;
const destMailbox = core.getContracts(destName).mailbox;
const isDelivered = await destMailbox.delivered(messageId);
if (isDelivered) {
@ -335,7 +343,7 @@ async function isContract(provider: providers.Provider, address: Address) {
async function debugMessageDelivery(
core: HyperlaneCore,
originDomain: number,
originDomain: DomainId,
destName: string,
sender: Address,
recipient: Address,
@ -343,10 +351,11 @@ async function debugMessageDelivery(
body: string,
destProvider: providers.Provider,
) {
const destMailbox = core.getContracts(destName).mailbox.contract;
const destMailbox = core.getContracts(destName).mailbox;
const recipientContract = IMessageRecipient__factory.connect(recipient, destProvider);
try {
// TODO add special case for Arbitrum:
// TODO account for mailbox handling gas overhead
// https://github.com/hyperlane-xyz/hyperlane-monorepo/pull/1949/files#diff-79ec1cf679507919c08a9a66e0407c16fff22aee98d79cf39a0c1baf086403ebR364
const deliveryGasEst = await recipientContract.estimateGas.handle(
originDomain,
@ -390,15 +399,17 @@ async function debugMessageDelivery(
}
async function isIgpUnderfunded(
core: HyperlaneCore,
env: Environment,
multiProvider: MultiProvider,
msgId: string,
originName: string,
deliveryGasEst?: string,
totalGasAmount?: string,
) {
const igp = core.getContracts(originName).interchainGasPaymaster.contract;
const igp = HyperlaneIgp.fromEnvironment(env, multiProvider);
const igpContract = igp.getContracts(originName).defaultIsmInterchainGasPaymaster;
const { isFunded, igpDetails } = await tryCheckIgpGasFunded(
igp,
igpContract,
msgId,
deliveryGasEst,
totalGasAmount,
@ -413,7 +424,7 @@ async function isIgpUnderfunded(
}
async function tryCheckIgpGasFunded(
igp: InterchainGasPaymaster,
igp: IInterchainGasPaymaster,
messageId: string,
deliveryGasEst?: string,
totalGasAmount?: string,
@ -472,7 +483,7 @@ async function tryDebugIcaMsg(
sender: Address,
recipient: Address,
body: string,
originDomainId: number,
originDomainId: DomainId,
destinationProvider: providers.Provider,
) {
if (!isIcaMessage({ sender, recipient })) return null;

@ -1,12 +1,12 @@
import { constants } from 'ethers';
import { MultiProvider, hyperlaneCoreAddresses } from '@hyperlane-xyz/sdk';
import { MultiProvider, hyperlaneEnvironments } from '@hyperlane-xyz/sdk';
import { getMultiProvider } from '../../multiProvider';
import { Message, MessageStatus } from '../../types';
import { queryExplorerForLogs, queryExplorerForTx } from '../../utils/explorers';
import { logger } from '../../utils/logger';
import { toDecimalNumber } from '../../utils/number';
import { getChainEnvironment } from '../chains/utils';
import { debugExplorerMessage } from '../debugger/debugMessage';
import { MessageDebugStatus } from '../debugger/types';
import { TX_HASH_ZERO } from '../messages/placeholderMessages';
@ -24,12 +24,13 @@ import {
const PROCESS_TOPIC_0 = '0x1cae38cdd3d3919489272725a5ae62a4f48b2989b0dae843d3c279fee18073a9';
export async function fetchDeliveryStatus(
multiProvider: MultiProvider,
message: Message,
multiProvider = getMultiProvider(),
): Promise<MessageDeliveryStatusResponse> {
const destName = multiProvider.getChainName(message.destinationChainId);
const destEnv = getChainEnvironment(multiProvider, destName);
// TODO PI support here
const destMailboxAddr = hyperlaneCoreAddresses[destName]?.mailbox;
const destMailboxAddr = hyperlaneEnvironments[destEnv][destName]?.mailbox;
if (!destMailboxAddr) throw new Error(`No mailbox address found for dest ${destName}`);
const logs = await fetchExplorerLogsForMessage(multiProvider, message, destMailboxAddr);
@ -95,7 +96,7 @@ function fetchExplorerLogsForMessage(
async function tryFetchTransactionDetails(
multiProvider: MultiProvider,
chainId: number,
chainId: ChainId,
txHash: string,
) {
try {

@ -2,22 +2,39 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { toast } from 'react-toastify';
import { chainIdToMetadata } from '@hyperlane-xyz/sdk';
import { useMultiProvider } from '../../multiProvider';
import { Message, MessageStatus } from '../../types';
import { logger } from '../../utils/logger';
import { MissingChainConfigToast } from '../chains/MissingChainConfigToast';
import { fetchDeliveryStatus } from './fetchDeliveryStatus';
export function useMessageDeliveryStatus(message: Message, isReady: boolean) {
export function useMessageDeliveryStatus({ message, pause }: { message: Message; pause: boolean }) {
const multiProvider = useMultiProvider();
const serializedMessage = JSON.stringify(message);
const { data, error } = useQuery(
['messageDeliveryStatus', serializedMessage, isReady],
['messageDeliveryStatus', serializedMessage, pause],
async () => {
if (pause || !message || message.status === MessageStatus.Delivered) return null;
if (!multiProvider.tryGetChainMetadata(message.originChainId)) {
toast.error(<MissingChainConfigToast chainId={message.originChainId} />);
} else if (!multiProvider.tryGetChainMetadata(message.destinationChainId)) {
toast.error(<MissingChainConfigToast chainId={message.destinationChainId} />);
}
// TODO enable PI support here
if (!isReady || !message || message.status === MessageStatus.Delivered || message.isPiMsg)
if (
message.isPiMsg ||
!chainIdToMetadata[message.originChainId] ||
!chainIdToMetadata[message.destinationChainId]
)
return null;
logger.debug('Fetching message delivery status for:', message.id);
const deliverStatus = await fetchDeliveryStatus(message);
const deliverStatus = await fetchDeliveryStatus(multiProvider, message);
logger.debug('Message delivery status result', deliverStatus);
return deliverStatus;
},

@ -1,15 +1,14 @@
import Image from 'next/image';
import { useCallback, useEffect, useMemo } from 'react';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useQuery } from 'urql';
import { Spinner } from '../../components/animation/Spinner';
import CheckmarkIcon from '../../images/icons/checkmark-circle.svg';
import { useMultiProvider } from '../../multiProvider';
import { useStore } from '../../store';
import { Message, MessageStatus } from '../../types';
import { logger } from '../../utils/logger';
import { toTitleCase } from '../../utils/string';
import { useInterval } from '../../utils/useInterval';
import { getChainDisplayName } from '../chains/utils';
import { useMessageDeliveryStatus } from '../deliveryStatus/useMessageDeliveryStatus';
@ -19,64 +18,79 @@ import { IcaDetailsCard } from './cards/IcaDetailsCard';
import { TimelineCard } from './cards/TimelineCard';
import { TransactionCard } from './cards/TransactionCard';
import { isIcaMessage } from './ica';
import { usePiChainMessageQuery } from './pi-queries/usePiChainMessageQuery';
import { PLACEHOLDER_MESSAGE } from './placeholderMessages';
import { MessageIdentifierType, buildMessageQuery } from './queries/build';
import { MessagesQueryResult } from './queries/fragments';
import { parseMessageQueryResult } from './queries/parse';
const AUTO_REFRESH_DELAY = 10000;
import { useMessageQuery } from './queries/useMessageQuery';
interface Props {
messageId: string; // Hex value for message id
message?: Message; // If provided, component will use this data instead of querying
}
export function MessageDetails({ messageId, message: propMessage }: Props) {
// Message query
const { query, variables } = buildMessageQuery(MessageIdentifierType.Id, messageId, 1);
const [{ data, fetching: isFetching, error }, reexecuteQuery] = useQuery<MessagesQueryResult>({
query,
variables,
pause: !!propMessage,
export function MessageDetails({ messageId, message: messageFromUrlParams }: Props) {
const multiProvider = useMultiProvider();
// Needed to force pause of message query if the useMessageDeliveryStatus
// Hook finds a delivery record on it's own
const [deliveryFound, setDeliveryFound] = useState(false);
// GraphQL query and results
const {
isFetching: isGraphQlFetching,
isError: isGraphQlError,
hasRun: hasGraphQlRun,
isMessageFound: isGraphQlMessageFound,
message: messageFromGraphQl,
} = useMessageQuery({ messageId, pause: !!messageFromUrlParams || deliveryFound });
// Run permissionless interop chains query if needed
const {
isError: isPiError,
isFetching: isPiFetching,
message: messageFromPi,
isMessageFound: isPiMessageFound,
} = usePiChainMessageQuery({
messageId,
pause: !!messageFromUrlParams || !hasGraphQlRun || isGraphQlMessageFound,
});
const graphQueryMessages = useMemo(() => parseMessageQueryResult(data), [data]);
// Extracting message properties
const _message = propMessage || graphQueryMessages[0] || PLACEHOLDER_MESSAGE;
const isMessageFound = !!propMessage || graphQueryMessages.length > 0;
// Coalesce GraphQL + PI results
const _message =
messageFromUrlParams || messageFromGraphQl || messageFromPi || PLACEHOLDER_MESSAGE;
const isMessageFound = !!messageFromUrlParams || isGraphQlMessageFound || isPiMessageFound;
const isFetching = isGraphQlFetching || isPiFetching;
const isError = isGraphQlError || isPiError;
const shouldBlur = !isMessageFound;
const isIcaMsg = isIcaMessage(_message);
// If message isn't delivered, query delivery-status api for
// more recent update and possibly debug info
const { messageWithDeliveryStatus: message, debugInfo } = useMessageDeliveryStatus(
_message,
isMessageFound,
);
// If message isn't delivered, attempt to check for
// more recent updates and possibly debug info
const { messageWithDeliveryStatus: message, debugInfo } = useMessageDeliveryStatus({
message: _message,
pause: !isMessageFound,
});
const { status, originChainId, destinationChainId: destChainId, origin, destination } = message;
// Query re-executor
const reExecutor = useCallback(() => {
if (propMessage || (isMessageFound && status === MessageStatus.Delivered)) return;
reexecuteQuery({ requestPolicy: 'network-only' });
}, [propMessage, isMessageFound, status, reexecuteQuery]);
useInterval(reExecutor, AUTO_REFRESH_DELAY);
// Mark delivery found to prevent pause queries
useEffect(() => {
if (status === MessageStatus.Delivered) setDeliveryFound(true);
}, [status]);
// Banner color setter
useDynamicBannerColor(isFetching, status, isMessageFound, error);
useDynamicBannerColor(isFetching, status, isMessageFound, isError || isPiError);
return (
<>
<div className="flex items-center justify-between px-1">
<h2 className="text-white text-lg">{`${
isIcaMsg ? 'ICA ' : ''
} Message to ${getChainDisplayName(destChainId)}`}</h2>
} Message to ${getChainDisplayName(multiProvider, destChainId)}`}</h2>
<StatusHeader
messageStatus={status}
isMessageFound={isMessageFound}
isFetching={isFetching}
isError={!!error}
isError={isError}
/>
</div>
<div className="flex flex-wrap items-stretch justify-between mt-5 gap-3">
@ -154,14 +168,14 @@ function useDynamicBannerColor(
isFetching: boolean,
status: MessageStatus,
isMessageFound: boolean,
error?: Error,
isError?: boolean,
) {
const setBanner = useStore((s) => s.setBanner);
useEffect(() => {
if (isFetching) return;
if (error) {
logger.error('Error fetching message details', error);
toast.error(`Error fetching message: ${error.message?.substring(0, 30)}`);
if (isError) {
logger.error('Error fetching message details');
toast.error('Error fetching message. Please check the message id and try again.');
setBanner('bg-red-500');
} else if (status === MessageStatus.Failing) {
setBanner('bg-red-500');
@ -170,7 +184,7 @@ function useDynamicBannerColor(
} else {
setBanner('');
}
}, [error, isFetching, status, isMessageFound, setBanner]);
}, [isError, isFetching, status, isMessageFound, setBanner]);
useEffect(() => {
return () => setBanner('');
}, [setBanner]);

@ -14,8 +14,8 @@ import { useQueryParam, useSyncQueryParam } from '../../utils/queryParams';
import { sanitizeString } from '../../utils/string';
import { MessageTable } from './MessageTable';
import { useMessageQuery } from './queries/useMessageQuery';
import { usePiChainMessageQuery } from './queries/usePiChainMessageQuery';
import { usePiChainMessageSearchQuery } from './pi-queries/usePiChainMessageQuery';
import { useMessageSearchQuery } from './queries/useMessageQuery';
const QUERY_SEARCH_PARAM = 'search';
@ -34,33 +34,34 @@ export function MessageSearch() {
const [endTimeFilter, setEndTimeFilter] = useState<number | null>(null);
// GraphQL query and results
const { isValidInput, isError, isFetching, hasRun, messageList } = useMessageQuery(
sanitizedInput,
originChainFilter,
destinationChainFilter,
startTimeFilter,
endTimeFilter,
);
const isMessagesFound = messageList.length > 0;
const { isValidInput, isError, isFetching, hasRun, messageList, isMessagesFound } =
useMessageSearchQuery(
sanitizedInput,
originChainFilter,
destinationChainFilter,
startTimeFilter,
endTimeFilter,
);
// Permissionless Interop query and results
// Run permissionless interop chains query if needed
const {
isError: isPiError,
isFetching: isPiFetching,
hasRun: hasPiRun,
messageList: piMessageList,
} = usePiChainMessageQuery(
isMessagesFound: isPiMessagesFound,
} = usePiChainMessageSearchQuery({
sanitizedInput,
startTimeFilter,
endTimeFilter,
!hasRun || isMessagesFound,
);
pause: !hasRun || isMessagesFound,
});
// Coalesce GraphQL + PI results
const isAnyFetching = isFetching || isPiFetching;
const isAnyError = isError || isPiError;
const hasAllRun = hasRun && hasPiRun;
const isAnyMessageFound = isMessagesFound || piMessageList.length > 0;
const isAnyMessageFound = isMessagesFound || isPiMessagesFound;
const messageListResult = isMessagesFound ? messageList : piMessageList;
// Keep url in sync

@ -1,7 +1,10 @@
import Link from 'next/link';
import { PropsWithChildren } from 'react';
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { ChainLogo } from '../../components/icons/ChainLogo';
import { useMultiProvider } from '../../multiProvider';
import { MessageStatus, MessageStub } from '../../types';
import { shortenAddress } from '../../utils/addresses';
import { getHumanReadableDuration, getHumanReadableTimeString } from '../../utils/time';
@ -16,6 +19,8 @@ export function MessageTable({
messageList: MessageStub[];
isFetching: boolean;
}) {
const multiProvider = useMultiProvider();
return (
<table className="w-full mb-1">
<thead>
@ -37,7 +42,7 @@ export function MessageTable({
isFetching && 'blur-xs'
} transition-all duration-500`}
>
<MessageSummaryRow message={m} />
<MessageSummaryRow message={m} mp={multiProvider} />
</tr>
))}
</tbody>
@ -45,7 +50,7 @@ export function MessageTable({
);
}
export function MessageSummaryRow({ message }: { message: MessageStub }) {
export function MessageSummaryRow({ message, mp }: { message: MessageStub; mp: MultiProvider }) {
const {
msgId,
status,
@ -76,11 +81,11 @@ export function MessageSummaryRow({ message }: { message: MessageStub }) {
<>
<LinkCell id={msgId} base64={base64} aClasses="flex items-center py-3.5 pl-3 sm:pl-5">
<ChainLogo chainId={originChainId} size={20} />
<div className={styles.chainName}>{getChainDisplayName(originChainId, true)}</div>
<div className={styles.chainName}>{getChainDisplayName(mp, originChainId, true)}</div>
</LinkCell>
<LinkCell id={msgId} base64={base64} aClasses="flex items-center py-3.5 ">
<ChainLogo chainId={destinationChainId} size={20} />
<div className={styles.chainName}>{getChainDisplayName(destinationChainId, true)}</div>
<div className={styles.chainName}>{getChainDisplayName(mp, destinationChainId, true)}</div>
</LinkCell>
<LinkCell id={msgId} base64={base64} tdClasses="hidden sm:table-cell" aClasses={styles.value}>
{shortenAddress(sender) || 'Invalid Address'}

@ -5,7 +5,7 @@ import { ChainLogo } from '../../../components/icons/ChainLogo';
import { HelpIcon } from '../../../components/icons/HelpIcon';
import { Card } from '../../../components/layout/Card';
import MailUnknown from '../../../images/icons/mail-unknown.svg';
import { getMultiProvider } from '../../../multiProvider';
import { useMultiProvider } from '../../../multiProvider';
import { MessageStatus, MessageTx } from '../../../types';
import { getDateTimeString, getHumanReadableTimeString } from '../../../utils/time';
import { getChainDisplayName } from '../../chains/utils';
@ -16,7 +16,7 @@ import { KeyValueRow } from './KeyValueRow';
interface TransactionCardProps {
title: string;
chainId: number;
chainId: ChainId;
status: MessageStatus;
transaction?: MessageTx;
debugInfo?: TransactionCardDebugInfo;
@ -27,7 +27,7 @@ interface TransactionCardProps {
export interface TransactionCardDebugInfo {
status: MessageDebugStatus;
details: string;
originChainId: number;
originChainId: ChainId;
originTxHash: string;
}
@ -40,8 +40,9 @@ export function TransactionCard({
helpText,
shouldBlur,
}: TransactionCardProps) {
const multiProvider = useMultiProvider();
const hash = transaction?.hash;
const txExplorerLink = hash ? getMultiProvider().tryGetExplorerTxUrl(chainId, { hash }) : null;
const txExplorerLink = hash ? multiProvider.tryGetExplorerTxUrl(chainId, { hash }) : null;
return (
<Card classes="flex-1 min-w-fit space-y-3">
<div className="flex items-center justify-between">
@ -58,7 +59,7 @@ export function TransactionCard({
<KeyValueRow
label="Chain:"
labelWidth="w-16"
display={`${getChainDisplayName(chainId)} (${chainId})`}
display={`${getChainDisplayName(multiProvider, chainId)} (${chainId})`}
displayWidth="w-60 sm:w-64"
blurValue={shouldBlur}
/>

@ -2,14 +2,14 @@ import { useQuery } from '@tanstack/react-query';
import { BigNumber, providers, utils } from 'ethers';
import { InterchainAccountRouter__factory } from '@hyperlane-xyz/core';
import { hyperlaneCoreAddresses } from '@hyperlane-xyz/sdk';
import { hyperlaneEnvironments } from '@hyperlane-xyz/sdk';
import { getMultiProvider } from '../../multiProvider';
import { useMultiProvider } from '../../multiProvider';
import { areAddressesEqual, isValidAddress } from '../../utils/addresses';
import { logger } from '../../utils/logger';
// This assumes all chains have the same ICA address
const ICA_ADDRESS = Object.values(hyperlaneCoreAddresses)[0].interchainAccountRouter;
const ICA_ADDRESS = hyperlaneEnvironments.mainnet.ethereum.interchainAccountRouter;
export function isIcaMessage({
sender,
@ -71,7 +71,7 @@ export function tryDecodeIcaBody(body: string) {
}
export async function tryFetchIcaAddress(
originDomainId: number,
originDomainId: DomainId,
sender: Address,
provider: providers.Provider,
) {
@ -93,12 +93,14 @@ export async function tryFetchIcaAddress(
}
}
export function useIcaAddress(originDomainId: number, sender?: Address | null) {
export function useIcaAddress(originDomainId: DomainId, sender?: Address | null) {
const multiProvider = useMultiProvider();
return useQuery(
['messageIcaAddress', originDomainId, sender],
['useIcaAddress', originDomainId, sender],
() => {
if (!originDomainId || !sender || BigNumber.from(sender).isZero()) return null;
const provider = getMultiProvider().getProvider(originDomainId);
const provider = multiProvider.tryGetProvider(originDomainId);
if (!provider) return null;
return tryFetchIcaAddress(originDomainId, sender, provider);
},
{ retry: false },

@ -1,8 +1,8 @@
import { MultiProvider, chainMetadata, hyperlaneCoreAddresses } from '@hyperlane-xyz/sdk';
import { MultiProvider, chainMetadata, hyperlaneEnvironments } from '@hyperlane-xyz/sdk';
import { ChainConfig } from '../../chains/chainConfig';
import { fetchMessagesFromPiChain } from './usePiChainMessageQuery';
import { fetchMessagesFromPiChain } from './fetchPiChainMessages';
// NOTE: THESE TESTS WILL NO LONGER WORK ONCE THE MESSAGE USED BELOW
// IS OUT OF PROVIDER_LOGS_BLOCK_WINDOW USED TO QUERY
@ -12,7 +12,7 @@ import { fetchMessagesFromPiChain } from './usePiChainMessageQuery';
jest.setTimeout(30000);
const multiProvider = new MultiProvider();
const goerliMailbox = hyperlaneCoreAddresses.goerli.mailbox;
const goerliMailbox = hyperlaneEnvironments.testnet.goerli.mailbox;
const goerliConfigWithExplorer: ChainConfig = {
...chainMetadata.goerli,
contracts: { mailbox: goerliMailbox },

@ -1,19 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { BigNumber, constants, ethers, providers } from 'ethers';
import { Mailbox__factory } from '@hyperlane-xyz/core';
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { utils } from '@hyperlane-xyz/utils';
import { getMultiProvider } from '../../../multiProvider';
import { useStore } from '../../../store';
import { LogWithTimestamp, Message, MessageStatus } from '../../../types';
import {
ensureLeading0x,
isValidAddress,
isValidTransactionHash,
normalizeAddress,
} from '../../../utils/addresses';
import { ExtendedLog, Message, MessageStatus } from '../../../types';
import { isValidAddress, isValidTransactionHash, normalizeAddress } from '../../../utils/addresses';
import {
queryExplorerForBlock,
queryExplorerForLogs,
@ -23,8 +15,6 @@ import {
import { logger } from '../../../utils/logger';
import { ChainConfig } from '../../chains/chainConfig';
import { isValidSearchQuery } from './useMessageQuery';
const PROVIDER_LOGS_BLOCK_WINDOW = 100_000;
const PROVIDER_BLOCK_DETAILS_WINDOW = 5_000;
@ -34,44 +24,16 @@ const dispatchIdTopic0 = mailbox.getEventTopic('DispatchId');
// const processTopic0 = mailbox.getEventTopic('Process');
// const processIdTopic0 = mailbox.getEventTopic('ProcessId');
// Query 'Permissionless Interoperability (PI)' chains using custom
// chain configs in store state
export function usePiChainMessageQuery(
sanitizedInput: string,
startTimeFilter: number | null,
endTimeFilter: number | null,
pause: boolean,
) {
const chainConfigs = useStore((s) => s.chainConfigs);
const { isLoading, isError, data } = useQuery(
['usePiChainMessageQuery', chainConfigs, sanitizedInput, startTimeFilter, endTimeFilter, pause],
async () => {
const hasInput = !!sanitizedInput;
const isValidInput = isValidSearchQuery(sanitizedInput, true);
if (pause || !hasInput || !isValidInput || !Object.keys(chainConfigs).length) return [];
logger.debug('Starting PI Chain message query for:', sanitizedInput);
// TODO convert timestamps to from/to blocks here
const query = { input: ensureLeading0x(sanitizedInput) };
const multiProvider = getMultiProvider();
try {
const messages = await Promise.any(
Object.values(chainConfigs).map((c) => fetchMessagesOrThrow(c, query, multiProvider)),
);
return messages;
} catch (e) {
logger.debug('Error fetching PI messages found for:', sanitizedInput, e);
return [];
}
},
{ retry: false },
);
export interface PiMessageQuery {
input: string;
fromBlock?: string | number;
toBlock?: string | number;
}
return {
isFetching: isLoading,
isError,
hasRun: !!data,
messageList: data || [],
};
export enum PiQueryType {
Address = 'address',
TxHash = 'txHash',
MsgId = 'msgId',
}
/* Pseudo-code for the fetch algo below:
@ -101,46 +63,43 @@ searchForMessages(input):
GOTO hash search above
*/
export interface PiMessageQuery {
input: string;
fromBlock?: string | number;
toBlock?: string | number;
}
async function fetchMessagesOrThrow(
chainConfig: ChainConfig,
query: PiMessageQuery,
multiProvider: MultiProvider,
): Promise<Message[]> {
const messages = await fetchMessagesFromPiChain(chainConfig, query, multiProvider);
// Throw so Promise.any caller doesn't trigger
if (!messages.length) throw new Error(`No messages found for chain ${chainConfig.chainId}`);
return messages;
}
export async function fetchMessagesFromPiChain(
chainConfig: ChainConfig,
query: PiMessageQuery,
multiProvider: MultiProvider,
queryType?: PiQueryType, // optionally force search down to just one type
): Promise<Message[]> {
const useExplorer = !!chainConfig.blockExplorers?.[0]?.apiUrl;
const input = query.input;
let logs: LogWithTimestamp[];
if (isValidAddress(input)) {
let logs: ExtendedLog[] = [];
if (isValidAddress(input) && (!queryType || queryType === PiQueryType.Address)) {
logs = await fetchLogsForAddress(chainConfig, query, multiProvider, useExplorer);
} else if (isValidTransactionHash(input)) {
logs = await fetchLogsForTxHash(chainConfig, query, multiProvider, useExplorer);
if (!queryType || queryType === PiQueryType.TxHash) {
logs = await fetchLogsForTxHash(chainConfig, query, multiProvider, useExplorer);
}
// Input may be a msg id, check that next
if (!logs.length) {
if ((!queryType || queryType === PiQueryType.MsgId) && !logs.length) {
logs = await fetchLogsForMsgId(chainConfig, query, multiProvider, useExplorer);
}
} else {
logger.warn('Invalid PI search input', input);
logger.warn('Invalid PI search input', input, queryType);
return [];
}
return logs.map((l) => logToMessage(l, chainConfig)).filter((m): m is Message => !!m);
const messages = logs
.map((l) => logToMessage(multiProvider, l, chainConfig))
.filter((m): m is Message => !!m);
const messagesWithGasPayments: Message[] = [];
// Avoiding parallelism here out of caution for RPC rate limits
for (const m of messages) {
messagesWithGasPayments.push(
await tryFetchIgpGasPayments(m, chainConfig, multiProvider, useExplorer),
);
}
return messagesWithGasPayments;
}
async function fetchLogsForAddress(
@ -148,7 +107,7 @@ async function fetchLogsForAddress(
query: PiMessageQuery,
multiProvider: MultiProvider,
useExplorer?: boolean,
): Promise<LogWithTimestamp[]> {
): Promise<ExtendedLog[]> {
const address = query.input;
logger.debug(`Fetching logs for address ${address} on chain ${chainId}`);
const mailboxAddr = contracts.mailbox;
@ -186,7 +145,7 @@ async function fetchLogsForTxHash(
query: PiMessageQuery,
multiProvider: MultiProvider,
useExplorer: boolean,
): Promise<LogWithTimestamp[]> {
): Promise<ExtendedLog[]> {
const txHash = query.input;
logger.debug(`Fetching logs for txHash ${txHash} on chain ${chainId}`);
if (useExplorer) {
@ -201,7 +160,7 @@ async function fetchLogsForTxHash(
);
return txReceipt.logs.map((l) => ({
...l,
timestamp: BigNumber.from(block.timestamp).toNumber() * 1000,
timestamp: parseBlockTimestamp(block),
from: txReceipt.from,
to: txReceipt.to,
}));
@ -214,11 +173,9 @@ async function fetchLogsForTxHash(
if (txReceipt) {
logger.debug(`Tx receipt found from provider for chain ${chainId}`);
const block = await tryFetchBlockFromProvider(provider, txReceipt.blockNumber);
// TODO make timestamp optional instead of using 0 fallback here
const timestamp = block ? BigNumber.from(block.timestamp).toNumber() * 1000 : 0;
return txReceipt.logs.map((l) => ({
...l,
timestamp,
timestamp: parseBlockTimestamp(block),
from: txReceipt.from,
to: txReceipt.to,
}));
@ -234,13 +191,13 @@ async function fetchLogsForMsgId(
query: PiMessageQuery,
multiProvider: MultiProvider,
useExplorer: boolean,
): Promise<LogWithTimestamp[]> {
): Promise<ExtendedLog[]> {
const { contracts, chainId } = chainConfig;
const msgId = query.input;
logger.debug(`Fetching logs for msgId ${msgId} on chain ${chainId}`);
const mailboxAddr = contracts.mailbox;
const topic1 = msgId;
let logs: LogWithTimestamp[];
let logs: ExtendedLog[];
if (useExplorer) {
logs = await fetchLogsFromExplorer(
[
@ -281,14 +238,14 @@ async function fetchLogsForMsgId(
async function fetchLogsFromExplorer(
paths: Array<string>,
contractAddr: Address,
chainId: number,
chainId: ChainId,
query: PiMessageQuery,
multiProvider: MultiProvider,
): Promise<LogWithTimestamp[]> {
): Promise<ExtendedLog[]> {
const fromBlock = query.fromBlock || '1';
const toBlock = query.toBlock || 'latest';
const base = `module=logs&action=getLogs&fromBlock=${fromBlock}&toBlock=${toBlock}&address=${contractAddr}`;
let logs: LogWithTimestamp[] = [];
let logs: ExtendedLog[] = [];
for (const path of paths) {
// Originally use parallel requests here with Promise.all but immediately hit rate limit errors
const result = await queryExplorerForLogs(multiProvider, chainId, `${base}${path}`, false);
@ -300,10 +257,10 @@ async function fetchLogsFromExplorer(
async function fetchLogsFromProvider(
topics: Array<Array<string | null>>,
contractAddr: Address,
chainId: number,
chainId: ChainId,
query: PiMessageQuery,
multiProvider: MultiProvider,
): Promise<LogWithTimestamp[]> {
): Promise<ExtendedLog[]> {
const provider = multiProvider.getProvider(chainId);
const latestBlock = await provider.getBlockNumber();
const fromBlock = query.fromBlock || latestBlock - PROVIDER_LOGS_BLOCK_WINDOW;
@ -322,15 +279,13 @@ async function fetchLogsFromProvider(
)
).flat();
const timestamps: Record<number, number> = {};
const logsWithTimestamp = await Promise.all<LogWithTimestamp>(
const timestamps: Record<number, number | undefined> = {};
const logsWithTimestamp = await Promise.all<ExtendedLog>(
logs.map(async (l) => {
const blockNum = l.blockNumber;
if (!timestamps[blockNum]) {
const block = await tryFetchBlockFromProvider(provider, blockNum, latestBlock);
// TODO make timestamps optional instead of using 0 fallback here
const timestamp = block ? BigNumber.from(block.timestamp).toNumber() * 1000 : 0;
timestamps[blockNum] = timestamp;
timestamps[blockNum] = parseBlockTimestamp(block);
}
return {
...l,
@ -357,7 +312,16 @@ async function tryFetchBlockFromProvider(
}
}
function logToMessage(log: LogWithTimestamp, chainConfig: ChainConfig): Message | null {
function parseBlockTimestamp(block: providers.Block | null): number | undefined {
if (!block) return undefined;
return BigNumber.from(block.timestamp).toNumber() * 1000;
}
function logToMessage(
multiProvider: MultiProvider,
log: ExtendedLog,
chainConfig: ChainConfig,
): Message | null {
let logDesc: ethers.utils.LogDescription;
try {
logDesc = mailbox.parseLog(log);
@ -368,7 +332,6 @@ function logToMessage(log: LogWithTimestamp, chainConfig: ChainConfig): Message
}
try {
const multiProvider = getMultiProvider();
const bytes = logDesc.args['message'];
const message = utils.parseMessage(bytes);
const msgId = utils.messageId(bytes);
@ -390,7 +353,7 @@ function logToMessage(log: LogWithTimestamp, chainConfig: ChainConfig): Message
destinationDomainId: message.destination,
body: message.body,
origin: {
timestamp: log.timestamp,
timestamp: log.timestamp || 0,
hash: log.transactionHash,
from: log.from ? normalizeAddress(log.from) : constants.AddressZero,
to: log.to ? normalizeAddress(log.to) : constants.AddressZero,
@ -414,3 +377,26 @@ function logToMessage(log: LogWithTimestamp, chainConfig: ChainConfig): Message
return null;
}
}
// Fetch and sum all IGP gas payments for a given message
async function tryFetchIgpGasPayments(
message: Message,
chainConfig: ChainConfig,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_multiProvider: MultiProvider,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_useExplorer?: boolean,
): Promise<Message> {
const { chainId, contracts } = chainConfig;
const igpAddr = contracts.interchainGasPaymaster;
if (!igpAddr || !isValidAddress(igpAddr)) {
logger.warn('No IGP address found for chain:', chainId);
return message;
}
// TODO implement gas payment fetching
// Mimic logic in debugger's tryCheckIgpGasFunded
// Either duplicate or refactor into shared util built on SmartProvider
return message;
}

@ -0,0 +1,120 @@
import { useQuery } from '@tanstack/react-query';
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { useMultiProvider } from '../../../multiProvider';
import { Message } from '../../../types';
import { ensureLeading0x } from '../../../utils/addresses';
import { logger } from '../../../utils/logger';
import { ChainConfig } from '../../chains/chainConfig';
import { useChainConfigsWithQueryParams } from '../../chains/useChainConfigs';
import { isValidSearchQuery } from '../queries/useMessageQuery';
import { PiMessageQuery, PiQueryType, fetchMessagesFromPiChain } from './fetchPiChainMessages';
// Query 'Permissionless Interoperability (PI)' chains using custom
// chain configs in store state
export function usePiChainMessageSearchQuery({
sanitizedInput,
startTimeFilter,
endTimeFilter,
pause,
}: {
sanitizedInput: string;
startTimeFilter: number | null;
endTimeFilter: number | null;
pause: boolean;
}) {
const chainConfigs = useChainConfigsWithQueryParams();
const multiProvider = useMultiProvider();
const { isLoading, isError, data } = useQuery(
[
'usePiChainMessageSearchQuery',
chainConfigs,
sanitizedInput,
startTimeFilter,
endTimeFilter,
pause,
],
async () => {
const hasInput = !!sanitizedInput;
const isValidInput = isValidSearchQuery(sanitizedInput, true);
if (pause || !hasInput || !isValidInput || !Object.keys(chainConfigs).length) return [];
logger.debug('Starting PI Chain message search for:', sanitizedInput);
// TODO convert timestamps to from/to blocks here
const query = { input: ensureLeading0x(sanitizedInput) };
try {
const messages = await Promise.any(
Object.values(chainConfigs).map((c) => fetchMessagesOrThrow(c, query, multiProvider)),
);
return messages;
} catch (e) {
logger.debug('Error fetching PI messages for:', sanitizedInput, e);
return [];
}
},
{ retry: false },
);
return {
isFetching: isLoading,
isError,
hasRun: !!data,
messageList: data || [],
isMessagesFound: !!data?.length,
};
}
export function usePiChainMessageQuery({
messageId,
pause,
}: {
messageId: string;
pause: boolean;
}) {
const chainConfigs = useChainConfigsWithQueryParams();
const multiProvider = useMultiProvider();
const { isLoading, isError, data } = useQuery(
['usePiChainMessageQuery', chainConfigs, messageId, pause],
async () => {
if (pause || !messageId || !Object.keys(chainConfigs).length) return [];
logger.debug('Starting PI Chain message query for:', messageId);
const query = { input: ensureLeading0x(messageId) };
try {
const messages = await Promise.any(
Object.values(chainConfigs).map((c) =>
fetchMessagesOrThrow(c, query, multiProvider, PiQueryType.MsgId),
),
);
return messages;
} catch (e) {
logger.debug('Error fetching PI messages for:', messageId, e);
return [];
}
},
{ retry: false },
);
const message = data?.length ? data[0] : null;
const isMessageFound = !!message;
return {
isFetching: isLoading,
isError,
hasRun: !!data,
message,
isMessageFound,
};
}
async function fetchMessagesOrThrow(
chainConfig: ChainConfig,
query: PiMessageQuery,
multiProvider: MultiProvider,
queryType?: PiQueryType,
): Promise<Message[]> {
const messages = await fetchMessagesFromPiChain(chainConfig, query, multiProvider, queryType);
// Throw so Promise.any caller doesn't trigger
if (!messages.length) throw new Error(`No messages found for chain ${chainConfig.chainId}`);
return messages;
}

@ -1,13 +1,19 @@
import { useCallback, useMemo } from 'react';
import { useQuery } from 'urql';
import { MessageStatus } from '../../../types';
import { isValidAddressFast, isValidTransactionHash } from '../../../utils/addresses';
import { useInterval } from '../../../utils/useInterval';
import { buildMessageSearchQuery } from '../queries/build';
import { MessagesStubQueryResult } from '../queries/fragments';
import { parseMessageStubResult } from '../queries/parse';
import {
MessageIdentifierType,
buildMessageQuery,
buildMessageSearchQuery,
} from '../queries/build';
import { MessagesQueryResult, MessagesStubQueryResult } from '../queries/fragments';
import { parseMessageQueryResult, parseMessageStubResult } from '../queries/parse';
const AUTO_REFRESH_DELAY = 15000;
const SEARCH_AUTO_REFRESH_DELAY = 15000;
const MSG_AUTO_REFRESH_DELAY = 10000;
const LATEST_QUERY_LIMIT = 12;
const SEARCH_QUERY_LIMIT = 50;
@ -18,7 +24,7 @@ export function isValidSearchQuery(input: string, allowAddress?: boolean) {
return false;
}
export function useMessageQuery(
export function useMessageSearchQuery(
sanitizedInput: string,
originChainFilter: string | null,
destinationChainFilter: string | null,
@ -49,6 +55,7 @@ export function useMessageQuery(
// Parse results
const messageList = useMemo(() => parseMessageStubResult(data), [data]);
const isMessagesFound = messageList.length > 0;
// Setup interval to re-query
const reExecutor = useCallback(() => {
@ -56,13 +63,47 @@ export function useMessageQuery(
reexecuteQuery({ requestPolicy: 'network-only' });
}
}, [reexecuteQuery, query, isValidInput]);
useInterval(reExecutor, AUTO_REFRESH_DELAY);
useInterval(reExecutor, SEARCH_AUTO_REFRESH_DELAY);
return {
isValidInput,
isFetching,
isError: !!error,
hasRun: !!data,
isMessagesFound,
messageList,
};
}
export function useMessageQuery({ messageId, pause }: { messageId: string; pause: boolean }) {
// Assemble GraphQL Query
const { query, variables } = buildMessageQuery(MessageIdentifierType.Id, messageId, 1);
// Execute query
const [{ data, fetching: isFetching, error }, reexecuteQuery] = useQuery<MessagesQueryResult>({
query,
variables,
pause,
});
// Parse results
const messageList = useMemo(() => parseMessageQueryResult(data), [data]);
const isMessageFound = messageList.length > 0;
const message = isMessageFound ? messageList[0] : null;
const msgStatus = message?.status;
// Setup interval to re-query
const reExecutor = useCallback(() => {
if (pause || (isMessageFound && msgStatus === MessageStatus.Delivered)) return;
reexecuteQuery({ requestPolicy: 'network-only' });
}, [pause, isMessageFound, msgStatus, reexecuteQuery]);
useInterval(reExecutor, MSG_AUTO_REFRESH_DELAY);
return {
isFetching,
isError: !!error,
hasRun: !!data,
isMessageFound,
message,
};
}

@ -1,21 +1,10 @@
import { Message, MessageStub } from '../../types';
import { logger } from '../../utils/logger';
import { fromBase64, toBase64 } from '../../utils/base64';
export function serializeMessage(msg: MessageStub | Message): string | undefined {
try {
return btoa(JSON.stringify(msg));
} catch (error) {
logger.error('Unable to serialize msg', msg);
return undefined;
}
return toBase64(msg);
}
export function deSerializeMessage(data: string | string[]): Message | undefined {
try {
const msg = Array.isArray(data) ? data[0] : data;
return JSON.parse(atob(msg));
} catch (error) {
logger.error('Unable to deserialize msg', data);
return undefined;
}
export function deserializeMessage<M extends MessageStub>(data: string | string[]): M | undefined {
return fromBase64<M>(data);
}

2
src/global.d.ts vendored

@ -1 +1,3 @@
declare type Address = string;
declare type ChainId = number;
declare type DomainId = number;

@ -1,23 +1,14 @@
import { MultiProvider, chainMetadata } from '@hyperlane-xyz/sdk';
import { useMemo } from 'react';
import { ChainConfig } from './features/chains/chainConfig';
import { MultiProvider, chainMetadata } from '@hyperlane-xyz/sdk';
let multiProvider: MultiProvider;
import { useChainConfigsWithQueryParams } from './features/chains/useChainConfigs';
export function getMultiProvider() {
if (!multiProvider) multiProvider = new MultiProvider();
export function useMultiProvider() {
const nameToConfig = useChainConfigsWithQueryParams();
const multiProvider = useMemo(
() => new MultiProvider({ ...chainMetadata, ...nameToConfig }),
[nameToConfig],
);
return multiProvider;
}
export function setMultiProviderChains(customChainConfigs: Record<number, ChainConfig>) {
const nameToChainConfig = {};
Object.values(customChainConfigs).forEach((c) => (nameToChainConfig[c.name] = c));
multiProvider = new MultiProvider({
...chainMetadata,
...nameToChainConfig,
});
}
export function getProvider(chainId) {
return getMultiProvider().getProvider(chainId);
}

@ -59,6 +59,13 @@ const ApiDocs: NextPage = () => {
<ul className="mt-1 pl-3">
<ParamItem name="query" desc="address or hash to search (string)" />
</ul>
<h5 className="mt-2 text-gray-600">
Action:<code className="ml-2">search-pi-messages</code>, Parameter (2 required):
</h5>
<ul className="mt-1 pl-3">
<ParamItem name="query" desc="address or hash to search (string)" />
<ParamItem name="body" desc="the request body must contain a valid chain config" />
</ul>
</div>
</Card>
</div>

@ -2,7 +2,7 @@ import { BigNumber } from 'ethers';
import type { NextApiRequest, NextApiResponse } from 'next';
import NextCors from 'nextjs-cors';
import { chainIdToMetadata } from '@hyperlane-xyz/sdk';
import { MultiProvider, chainIdToMetadata } from '@hyperlane-xyz/sdk';
import { Environment } from '../../consts/environments';
import { getChainEnvironment } from '../../features/chains/utils';
@ -19,10 +19,11 @@ export default async function handler(
optionsSuccessStatus: 200,
});
try {
const body = req.body as { chainId: number };
const body = req.body as { chainId: ChainId };
if (!body.chainId) throw new Error('No chainId in body');
if (!chainIdToMetadata[body.chainId]) throw new Error('ChainId is unsupported');
const nonce = await fetchLatestNonce(body.chainId);
const multiProvider = new MultiProvider();
const nonce = await fetchLatestNonce(multiProvider, body.chainId);
res.status(200).json({ nonce });
} catch (error) {
const msg = 'Unable to fetch latest index';
@ -31,9 +32,9 @@ export default async function handler(
}
}
async function fetchLatestNonce(chainId: number) {
async function fetchLatestNonce(multiProvider: MultiProvider, chainId: ChainId) {
logger.debug(`Attempting to fetch nonce for:`, chainId);
const url = getS3BucketUrl(chainId);
const url = getS3BucketUrl(multiProvider, chainId);
logger.debug(`Querying bucket:`, url);
const response = await fetchWithTimeout(url, undefined, 3000);
const text = await response.text();
@ -43,10 +44,10 @@ async function fetchLatestNonce(chainId: number) {
}
// Partly copied from https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/1fc65f3b7f31f86722204a9de08506f212720a52/typescript/infra/config/environments/mainnet/validators.ts#L12
function getS3BucketUrl(chainId: number) {
const chainName = chainIdToMetadata[chainId].name;
function getS3BucketUrl(multiProvider: MultiProvider, chainId: ChainId) {
const chainName = multiProvider.getChainName(chainId);
const environment =
getChainEnvironment(chainId) === Environment.Mainnet ? 'mainnet2' : 'testnet3';
getChainEnvironment(multiProvider, chainId) === Environment.Mainnet ? 'mainnet2' : 'testnet3';
const bucketName = `hyperlane-${environment}-${chainName}-validator-0`;
return `https://${bucketName}.s3.us-east-1.amazonaws.com/checkpoint_latest_index.json`;
}

@ -3,7 +3,7 @@ import type { NextPage } from 'next';
import { ContentFrame } from '../components/layout/ContentFrame';
import { TxDebugger } from '../features/debugger/TxDebugger';
const Debugger: NextPage = () => {
const DebuggerPage: NextPage = () => {
return (
<ContentFrame>
<TxDebugger />
@ -18,4 +18,4 @@ export function getServerSideProps() {
};
}
export default Debugger;
export default DebuggerPage;

@ -3,7 +3,7 @@ import type { NextPage } from 'next';
import { ContentFrame } from '../components/layout/ContentFrame';
import { MessageSearch } from '../features/messages/MessageSearch';
const Home: NextPage = () => {
const HomePage: NextPage = () => {
return (
<ContentFrame>
<MessageSearch />
@ -18,4 +18,4 @@ export function getServerSideProps() {
};
}
export default Home;
export default HomePage;

@ -4,10 +4,11 @@ import { useEffect } from 'react';
import { ContentFrame } from '../../components/layout/ContentFrame';
import { MessageDetails } from '../../features/messages/MessageDetails';
import { deSerializeMessage } from '../../features/messages/utils';
import { deserializeMessage } from '../../features/messages/utils';
import { Message } from '../../types';
import { logger } from '../../utils/logger';
const Message: NextPage = () => {
const MessagePage: NextPage = () => {
const router = useRouter();
const { messageId, data } = router.query;
@ -17,7 +18,7 @@ const Message: NextPage = () => {
}, [router, messageId]);
if (!messageId || typeof messageId !== 'string') return null;
const message = data ? deSerializeMessage(data) : undefined;
const message = data ? deserializeMessage<Message>(data) : undefined;
return (
<ContentFrame>
@ -33,4 +34,4 @@ export async function getServerSideProps() {
};
}
export default Message;
export default MessagePage;

@ -2,7 +2,7 @@ import type { NextPage } from 'next';
import { ConfigureChains } from '../features/chains/ConfigureChains';
const Settings: NextPage = () => {
const SettingsPage: NextPage = () => {
return (
<div className="mt-4 mb-2 px-2 sm:px-6 lg:pr-14 w-full">
<ConfigureChains />
@ -10,4 +10,4 @@ const Settings: NextPage = () => {
);
};
export default Settings;
export default SettingsPage;

@ -1,16 +1,16 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { ChainMap } from '@hyperlane-xyz/sdk';
import { Environment } from './consts/environments';
import { ChainConfig } from './features/chains/chainConfig';
import { setMultiProviderChains } from './multiProvider';
import { logger } from './utils/logger';
// Keeping everything here for now as state is simple
// Will refactor into slices as necessary
interface AppState {
chainConfigs: Record<number, ChainConfig>;
setChainConfigs: (configs: Record<number, ChainConfig>) => void;
chainConfigsV2: ChainMap<ChainConfig>; // v2 because schema changed
setChainConfigs: (configs: ChainMap<ChainConfig>) => void;
environment: Environment;
setEnvironment: (env: Environment) => void;
bannerClassName: string;
@ -20,10 +20,9 @@ interface AppState {
export const useStore = create<AppState>()(
persist(
(set) => ({
chainConfigs: {},
setChainConfigs: (configs: Record<number, ChainConfig>) => {
set(() => ({ chainConfigs: configs }));
setMultiProviderChains(configs);
chainConfigsV2: {},
setChainConfigs: (configs: ChainMap<ChainConfig>) => {
set(() => ({ chainConfigsV2: configs }));
},
environment: Environment.Mainnet,
setEnvironment: (env: Environment) => set(() => ({ environment: env })),
@ -32,25 +31,7 @@ export const useStore = create<AppState>()(
}),
{
name: 'hyperlane', // name in storage
partialize: (state) => ({ chainConfigs: state.chainConfigs }), // fields to persist
onRehydrateStorage: () => (state, error) => {
if (state?.chainConfigs) setMultiProviderChains(state.chainConfigs);
else if (error) logger.debug('Error rehydrating store', error);
},
partialize: (state) => ({ chainConfigsV2: state.chainConfigsV2 }), // fields to persist
},
),
);
export function useChainConfigs() {
return useStore((s) => ({
chainConfigs: s.chainConfigs,
setChainConfigs: s.setChainConfigs,
}));
}
export function useEnvironment() {
return useStore((s) => ({
environment: s.environment,
setEnvironment: s.setEnvironment,
}));
}

@ -36,9 +36,9 @@ export interface MessageStub {
nonce: number; // formerly leafIndex
sender: Address;
recipient: Address;
originChainId: number;
originChainId: ChainId;
originDomainId: number;
destinationChainId: number;
destinationChainId: ChainId;
destinationDomainId: number;
origin: MessageTxStub;
destination?: MessageTxStub;
@ -55,8 +55,8 @@ export interface Message extends MessageStub {
numPayments?: number;
}
export interface LogWithTimestamp extends providers.Log {
timestamp: number;
export interface ExtendedLog extends providers.Log {
timestamp?: number;
from?: Address;
to?: Address;
}

@ -0,0 +1,22 @@
import { logger } from './logger';
export function toBase64(data: any): string | undefined {
try {
if (!data) throw new Error('No data to encode');
return btoa(JSON.stringify(data));
} catch (error) {
logger.error('Unable to serialize + encode data to base64', data);
return undefined;
}
}
export function fromBase64<T>(data: string | string[]): T | undefined {
try {
if (!data) throw new Error('No data to decode');
const msg = Array.isArray(data) ? data[0] : data;
return JSON.parse(atob(msg));
} catch (error) {
logger.error('Unable to decode + deserialize data from base64', data);
return undefined;
}
}

@ -3,7 +3,7 @@ import { BigNumber, providers } from 'ethers';
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { config } from '../consts/config';
import type { LogWithTimestamp } from '../types';
import type { ExtendedLog } from '../types';
import { logger } from './logger';
import { toDecimalNumber, tryToDecimalNumber } from './number';
@ -21,7 +21,7 @@ export interface ExplorerQueryResponse<R> {
async function queryExplorer<P>(
multiProvider: MultiProvider,
chainId: number,
chainId: ChainId,
params: URLSearchParams,
useKey = false,
) {
@ -85,7 +85,7 @@ export interface ExplorerLogEntry {
export async function queryExplorerForLogs(
multiProvider: MultiProvider,
chainId: number,
chainId: ChainId,
params: string,
useKey = false,
): Promise<ExplorerLogEntry[]> {
@ -113,7 +113,7 @@ function validateExplorerLog(log: ExplorerLogEntry) {
if (!log.timeStamp) throw new Error('Log has no timestamp');
}
export function toProviderLog(log: ExplorerLogEntry): LogWithTimestamp {
export function toProviderLog(log: ExplorerLogEntry): ExtendedLog {
return {
...log,
blockHash: '',
@ -127,7 +127,7 @@ export function toProviderLog(log: ExplorerLogEntry): LogWithTimestamp {
export async function queryExplorerForTx(
multiProvider: MultiProvider,
chainId: number,
chainId: ChainId,
txHash: string,
useKey = false,
) {
@ -152,7 +152,7 @@ export async function queryExplorerForTx(
export async function queryExplorerForTxReceipt(
multiProvider: MultiProvider,
chainId: number,
chainId: ChainId,
txHash: string,
useKey = false,
) {
@ -177,7 +177,7 @@ export async function queryExplorerForTxReceipt(
export async function queryExplorerForBlock(
multiProvider: MultiProvider,
chainId: number,
chainId: ChainId,
blockNumber?: number | string,
useKey = false,
) {

@ -21,8 +21,16 @@ export function useQueryParam(key: string, defaultVal = '') {
// Keep value in sync with query param in URL
export function useSyncQueryParam(key: string, value = '') {
const router = useRouter();
const { pathname, query } = router;
useEffect(() => {
const path = value ? `/?${key}=${value}` : '/';
const newQuery = new URLSearchParams(
Object.fromEntries(
Object.entries(query).filter((kv): kv is [string, string] => typeof kv[0] === 'string'),
),
);
if (value) newQuery.set(key, value);
else newQuery.delete(key);
const path = `${pathname}?${newQuery.toString()}`;
router
.replace(path, undefined, { shallow: true })
.catch((e) => logger.error('Error shallow updating url', e));

@ -1294,14 +1294,14 @@ __metadata:
languageName: node
linkType: hard
"@hyperlane-xyz/core@npm:1.2.3":
version: 1.2.3
resolution: "@hyperlane-xyz/core@npm:1.2.3"
"@hyperlane-xyz/core@npm:1.3.2":
version: 1.3.2
resolution: "@hyperlane-xyz/core@npm:1.3.2"
dependencies:
"@hyperlane-xyz/utils": 1.2.3
"@hyperlane-xyz/utils": 1.3.2
"@openzeppelin/contracts": ^4.8.0
"@openzeppelin/contracts-upgradeable": ^4.8.0
checksum: c7e8f5c37d2e6874b6ca5f16024a4ea8b413393a2a4bb770a467ca7cc031b51a89fbe6989598c171de38acafcd2e990525be0274f793742faafc832a0db359fa
checksum: f195319c458c8d43d49e06790bb975c1940c06cab7e7c4d99ac9128c39b8c4c0c10b806c4bef1d5f2a97162fead998782a7e1292bc968751a824a89ad90c90ff
languageName: node
linkType: hard
@ -1310,8 +1310,8 @@ __metadata:
resolution: "@hyperlane-xyz/explorer@workspace:."
dependencies:
"@headlessui/react": ^1.7.11
"@hyperlane-xyz/sdk": 1.2.3
"@hyperlane-xyz/widgets": 1.2.3
"@hyperlane-xyz/sdk": 1.3.2
"@hyperlane-xyz/widgets": 1.3.2
"@metamask/jazzicon": "https://github.com/jmrossy/jazzicon#7a8df28974b4e81129bfbe3cab76308b889032a6"
"@rainbow-me/rainbowkit": ^0.11.0
"@tanstack/react-query": ^4.24.10
@ -1349,39 +1349,41 @@ __metadata:
languageName: unknown
linkType: soft
"@hyperlane-xyz/sdk@npm:1.2.3":
version: 1.2.3
resolution: "@hyperlane-xyz/sdk@npm:1.2.3"
"@hyperlane-xyz/sdk@npm:1.3.2":
version: 1.3.2
resolution: "@hyperlane-xyz/sdk@npm:1.3.2"
dependencies:
"@hyperlane-xyz/core": 1.2.3
"@hyperlane-xyz/utils": 1.2.3
"@hyperlane-xyz/core": 1.3.2
"@hyperlane-xyz/utils": 1.3.2
"@types/coingecko-api": ^1.0.10
"@types/debug": ^4.1.7
"@wagmi/chains": ^0.2.6
coingecko-api: ^1.0.10
cross-fetch: ^3.1.5
debug: ^4.3.4
ethers: ^5.7.2
zod: ^3.21.2
checksum: a1afc8bdfe64916fdc6623c1df06bbe1393359fe6b29cefe3f25bb5ded16108e1bac037da9a29df7f0d61cc84940e571d148e41c7fd8539964a87e9a95c03c35
checksum: b2e54374eab564505454eaf51ea270758b1b7331c9e879bd0084c28d339e099ae68715b937001092689a526f4b2fa1eb448c2a836e9bfd9923a5fd4cf2564284
languageName: node
linkType: hard
"@hyperlane-xyz/utils@npm:1.2.3":
version: 1.2.3
resolution: "@hyperlane-xyz/utils@npm:1.2.3"
"@hyperlane-xyz/utils@npm:1.3.2":
version: 1.3.2
resolution: "@hyperlane-xyz/utils@npm:1.3.2"
dependencies:
ethers: ^5.7.2
checksum: 14222632d7ef2419f6b698afa0c4c466302dd0eb6f97d3c9031b7d42c033418547bf4092d480cc4346331f0233d4076caae75b86ffa2b7beed79741a146d3325
checksum: e7cdeb4b14ccabbd46f46d0329d81792c5483e98463a5080c884bc8bff833aa8160c2ab107cda843a3f6c23979d9ca7b6d314c2cfc393c786cbf5fc2f8680934
languageName: node
linkType: hard
"@hyperlane-xyz/widgets@npm:1.2.3":
version: 1.2.3
resolution: "@hyperlane-xyz/widgets@npm:1.2.3"
"@hyperlane-xyz/widgets@npm:1.3.2":
version: 1.3.2
resolution: "@hyperlane-xyz/widgets@npm:1.3.2"
peerDependencies:
"@hyperlane-xyz/sdk": ^1.2.3
"@hyperlane-xyz/sdk": ^1.3.2
react: ^18
react-dom: ^18
checksum: 7695de9b9a89d8ef7c6b3ddb2c6b5874fe27514972d2080d9eade05bf2ad68ce2389706090dd7498436ec719a4c64b2ebf6246c6527a0c8fc2f0daf3144b1b28
checksum: 990e68273a495b99a1cac6cae13998692775b11c62a5508591ff6836661e3f6de94bf1b4db1532c3d560cc7759e7266146178473bf8dc3b616aa974e36171c6a
languageName: node
linkType: hard
@ -2572,6 +2574,13 @@ __metadata:
languageName: node
linkType: hard
"@types/coingecko-api@npm:^1.0.10":
version: 1.0.10
resolution: "@types/coingecko-api@npm:1.0.10"
checksum: e9683f9ea9ce2f855f6565089981dd3fceb6c4674365438f3fc3877d089a2fb82cdea011b59d59c7baa1635dc610860cd29a10a4b7a650ff96521ead46f22a50
languageName: node
linkType: hard
"@types/connect@npm:^3.4.33":
version: 3.4.35
resolution: "@types/connect@npm:3.4.35"
@ -2581,6 +2590,15 @@ __metadata:
languageName: node
linkType: hard
"@types/debug@npm:^4.1.7":
version: 4.1.7
resolution: "@types/debug@npm:4.1.7"
dependencies:
"@types/ms": "*"
checksum: 0a7b89d8ed72526858f0b61c6fd81f477853e8c4415bb97f48b1b5545248d2ae389931680b94b393b993a7cfe893537a200647d93defe6d87159b96812305adc
languageName: node
linkType: hard
"@types/graceful-fs@npm:^4.1.3":
version: 4.1.6
resolution: "@types/graceful-fs@npm:4.1.6"
@ -2639,6 +2657,13 @@ __metadata:
languageName: node
linkType: hard
"@types/ms@npm:*":
version: 0.7.31
resolution: "@types/ms@npm:0.7.31"
checksum: daadd354aedde024cce6f5aa873fefe7b71b22cd0e28632a69e8b677aeb48ae8caa1c60e5919bb781df040d116b01cb4316335167a3fc0ef6a63fa3614c0f6da
languageName: node
linkType: hard
"@types/node@npm:*":
version: 17.0.41
resolution: "@types/node@npm:17.0.41"

Loading…
Cancel
Save