Merge pull request #32 from hyperlane-xyz/new-db-schema

Update to new schema and add IGP gas debugging
pi-gas-details
J M Rossy 2 years ago committed by GitHub
commit f4a2ee9610
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      package.json
  2. 41
      src/components/buttons/RadioButtons.tsx
  3. 35
      src/components/icons/InterchainAccount.tsx
  4. 7
      src/consts/addresses.ts
  5. 4
      src/consts/config.ts
  6. 1
      src/consts/environments.ts
  7. 2
      src/consts/values.ts
  8. 7
      src/features/api/types.ts
  9. 425
      src/features/debugger/debugMessage.ts
  10. 1
      src/features/debugger/strings.ts
  11. 1
      src/features/debugger/types.ts
  12. 139
      src/features/deliveryStatus/fetchDeliveryStatus.ts
  13. 4
      src/features/deliveryStatus/types.ts
  14. 58
      src/features/deliveryStatus/useMessageDeliveryStatus.ts
  15. 118
      src/features/messages/MessageDetails.tsx
  16. 10
      src/features/messages/MessageTable.tsx
  17. 71
      src/features/messages/cards/ContentDetailsCard.tsx
  18. 111
      src/features/messages/cards/GasDetailsCard.tsx
  19. 5
      src/features/messages/cards/IcaDetailsCard.tsx
  20. 8
      src/features/messages/cards/KeyValueRow.tsx
  21. 32
      src/features/messages/cards/TimelineCard.tsx
  22. 16
      src/features/messages/cards/TransactionCard.tsx
  23. 36
      src/features/messages/ica.ts
  24. 30
      src/features/messages/placeholderMessages.ts
  25. 44
      src/features/messages/queries/build.ts
  26. 12
      src/features/messages/queries/encoding.ts
  27. 227
      src/features/messages/queries/fragments.ts
  28. 129
      src/features/messages/queries/parse.ts
  29. 7
      src/features/messages/queries/types.ts
  30. 4
      src/features/messages/queries/useMessageQuery.ts
  31. 43
      src/features/messages/queries/usePiChainMessageQuery.ts
  32. 1
      src/images/icons/account-star.svg
  33. 4
      src/images/icons/envelope-check.svg
  34. 1
      src/images/icons/envelope-info.svg
  35. 4
      src/images/icons/fuel-pump.svg
  36. 1
      src/images/icons/lock.svg
  37. 3
      src/images/icons/paper-airplane.svg
  38. 3
      src/images/icons/shield-check.svg
  39. 39
      src/pages/api/delivery-status.ts
  40. 1
      src/pages/api/latest-nonce.ts
  41. 55
      src/types.ts
  42. 71
      src/utils/amount.ts
  43. 30
      src/utils/explorers.ts
  44. 12
      src/utils/number.ts
  45. 50
      yarn.lock

@ -5,11 +5,12 @@
"author": "J M Rossy",
"dependencies": {
"@headlessui/react": "^1.7.11",
"@hyperlane-xyz/sdk": "1.2.1",
"@hyperlane-xyz/widgets": "1.2.2-beta0",
"@hyperlane-xyz/sdk": "1.2.2",
"@hyperlane-xyz/widgets": "1.2.2",
"@metamask/jazzicon": "https://github.com/jmrossy/jazzicon#7a8df28974b4e81129bfbe3cab76308b889032a6",
"@rainbow-me/rainbowkit": "^0.11.0",
"@tanstack/react-query": "^4.24.10",
"bignumber.js": "^9.1.1",
"buffer": "^6.0.3",
"ethers": "^5.7.2",
"formik": "^2.2.9",

@ -0,0 +1,41 @@
import { RadioGroup } from '@headlessui/react';
interface Props {
options: Array<{ value: string; display: string }>;
selected: string;
onChange: (value: string) => void;
label?: string;
}
export function RadioButtons({ options, selected, onChange, label }: Props) {
return (
<div className="rounded border border-gray-200 overflow-hidden">
<RadioGroup value={selected} onChange={onChange}>
{label && <RadioGroup.Label className="sr-only">{label}</RadioGroup.Label>}
<div className="flex items-center divide-x">
{options.map((o) => (
<RadioGroup.Option
key={o.value}
value={o.value}
className={({ checked }) =>
`${checked ? 'bg-blue-500 hover:bg-blue-400' : 'bg-white hover:bg-gray-100'}
relative flex cursor-pointer px-2 py-1.5 outline-none`
}
>
{({ checked }) => (
<div className="flex w-full items-center justify-between">
<RadioGroup.Label
as="p"
className={`text-xs font-medium ${checked ? 'text-white' : 'text-gray-700'}`}
>
{o.display}
</RadioGroup.Label>
</div>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
);
}

@ -1,35 +0,0 @@
import Image from 'next/image';
import { memo } from 'react';
import ArrowRightIcon from '../../images/icons/arrow-right-short.svg';
import Asterisk from '../../images/icons/asterisk.svg';
import Key from '../../images/icons/key.svg';
import { useIsMobile } from '../../styles/mediaQueries';
function _InterchainAccount({ size = 34, arrowSize = 32 }: { size?: number; arrowSize?: number }) {
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 md:space-x-2">
<div
style={{ width: `${size}px`, height: `${size}px` }}
className="flex items-center justify-center rounded-full bg-gray-100 transition-all"
>
<Image src={Key} alt="" width={Math.floor(size / 2)} height={Math.floor(size / 2.2)} />
</div>
<Image src={ArrowRightIcon} width={arrowSize} height={arrowSize} alt="" />
<div
style={{ width: `${size}px`, height: `${size}px` }}
className="flex items-center justify-center rounded-full bg-gray-100 transition-all"
>
<Image src={Asterisk} alt="" width={Math.floor(size / 2.6)} height={Math.floor(size / 3)} />
</div>
</div>
);
}
export const InterchainAccount = memo(_InterchainAccount);

@ -1,7 +0,0 @@
// TODO hard-coding TestRecipient contract address here for now.
// From typescript/infra/config/environments/mainnet/testrecipient/addresses.json
// Moving to SDK doesn't quite feel right since it's not useful to others
// Must be updated when contracts get re-deployed
export const TEST_RECIPIENT_ADDRESS = '0xBC3cFeca7Df5A45d61BC60E7898E63670e1654aE';
// TODO add HelloWorld address when it's consistent across chains

@ -5,7 +5,6 @@ const explorerApiKeys = JSON.parse(process?.env?.EXPLORER_API_KEYS || '{}');
interface Config {
debug: boolean;
version: string | null;
url: string;
apiUrl: string;
explorerApiKeys: Record<string, string>;
}
@ -13,7 +12,6 @@ interface Config {
export const config: Config = Object.freeze({
debug: isDevMode,
version,
url: 'https://explorer.hyperlane.xyz',
apiUrl: 'https://api.hyperlane.xyz/v1/graphql',
apiUrl: 'https://hyperlane-explorer-3.hasura.app/v1/graphql',
explorerApiKeys,
});

@ -1,3 +1,4 @@
// Must match coreEnvironments in SDK
export enum Environment {
Mainnet = 'mainnet',
Testnet = 'testnet',

@ -0,0 +1,2 @@
export const MIN_ROUNDED_VALUE = 0.00001;
export const DISPLAY_DECIMALS = 5;

@ -13,17 +13,12 @@ export type ApiHandlerResult<T> =
export type ApiMessage = Omit<
Message,
| 'msgId' // use id field for msgId
| 'originChainId'
| 'destinationChainId'
| 'originTimestamp'
| 'destinationTimestamp'
| 'decodedBody'
>;
export function toApiMessage(message: Message): ApiMessage {
// prettier-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {msgId, originChainId, destinationChainId, originTimestamp, destinationTimestamp, decodedBody, ...rest} = message
const { msgId, decodedBody, ...rest } = message;
return {
...rest,
id: msgId,

@ -1,24 +1,30 @@
// Based on debug script in monorepo
// Forked from debug script in monorepo
// https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/typescript/infra/scripts/debug-message.ts
import { BigNumber, providers } from 'ethers';
import { IMessageRecipient__factory, Mailbox } from '@hyperlane-xyz/core';
import {
IMessageRecipient__factory,
type InterchainGasPaymaster,
type Mailbox,
} from '@hyperlane-xyz/core';
import {
ChainName,
CoreChainName,
DispatchedMessage,
HyperlaneCore,
MultiProvider,
TestChains,
} from '@hyperlane-xyz/sdk';
// TODO get exported from SDK properly
import { TestChains } from '@hyperlane-xyz/sdk/dist/consts/chains';
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';
import { logger } from '../../utils/logger';
import { chunk, trimToLength } from '../../utils/string';
import { getChainEnvironment } from '../chains/utils';
import { isIcaMessage, tryDecodeIcaBody, tryFetchIcaAddress } from '../messages/ica';
import {
@ -34,11 +40,8 @@ const HANDLE_FUNCTION_SIG = 'handle(uint32,bytes32,bytes)';
export async function debugMessagesForHash(
txHash: string,
environment: Environment,
attemptGetProcessTx = true,
multiProvider = getMultiProvider(),
): Promise<MessageDebugResult> {
// TODO use RPC with api keys
const multiProvider = new MultiProvider();
const txDetails = await findTransactionDetails(txHash, multiProvider);
if (!txDetails?.transactionReceipt) {
return {
@ -48,30 +51,25 @@ export async function debugMessagesForHash(
}
const { chainName, transactionReceipt } = txDetails;
return debugMessagesForTransaction(
chainName,
transactionReceipt,
environment,
undefined,
attemptGetProcessTx,
multiProvider,
);
return debugMessagesForTransaction(chainName, transactionReceipt, environment, multiProvider);
}
export async function debugMessagesForTransaction(
chainName: ChainName,
txReceipt: providers.TransactionReceipt,
environment: Environment,
multiProvider = getMultiProvider(),
nonce?: number,
attemptGetProcessTx = true,
multiProvider = new MultiProvider(),
): Promise<MessageDebugResult> {
const explorerLink = multiProvider.getExplorerTxUrl(chainName, {
hash: txReceipt.transactionHash,
});
// TODO PI support here
const core = HyperlaneCore.fromEnvironment(environment, multiProvider);
const dispatchedMessages = core.getDispatchedMessages(txReceipt);
const explorerLink =
multiProvider.tryGetExplorerTxUrl(chainName, {
hash: txReceipt.transactionHash,
}) || undefined;
if (!dispatchedMessages?.length) {
return {
status: TxDebugStatus.NoMessages,
@ -91,7 +89,7 @@ export async function debugMessagesForTransaction(
continue;
}
logger.debug(`Checking message ${i + 1} of ${dispatchedMessages.length}`);
messageDetails.push(await checkMessage(core, multiProvider, msg, attemptGetProcessTx));
messageDetails.push(await debugDispatchedMessage(core, multiProvider, msg));
logger.debug(`Done checking message ${i + 1}`);
}
return {
@ -102,6 +100,125 @@ export async function debugMessagesForTransaction(
};
}
async function debugDispatchedMessage(
core: HyperlaneCore,
multiProvider: MultiProvider,
message: DispatchedMessage,
): Promise<MessageDebugDetails> {
const {
sender: senderBytes,
recipient: recipientBytes,
origin: originDomain,
destination: destDomain,
body,
nonce,
} = message.parsed;
const messageId = utils.messageId(message.message);
const senderAddr = utils.bytes32ToAddress(senderBytes.toString());
const recipientAddr = utils.bytes32ToAddress(recipientBytes.toString());
const originName = multiProvider.getChainName(originDomain);
const destName = multiProvider.tryGetChainName(destDomain)!;
const properties = new Map<string, string | LinkProperty>();
properties.set('ID', messageId);
properties.set('Sender', senderAddr);
properties.set('Recipient', recipientAddr);
properties.set('Origin Domain', originDomain.toString());
properties.set('Origin Chain', originName);
properties.set('Destination Domain', destDomain.toString());
properties.set('Destination Chain', destName || 'Unknown');
properties.set('Nonce', nonce.toString());
properties.set('Raw Bytes', message.message);
const destInvalid = isInvalidDestDomain(core, destDomain, destName);
if (destInvalid) return { ...destInvalid, properties };
const messageDelivered = await isMessageAlreadyDelivered(
core,
multiProvider,
destName,
messageId,
properties,
);
if (messageDelivered) return { ...messageDelivered, properties };
const destProvider = multiProvider.getProvider(destName);
const recipInvalid = await isInvalidRecipient(destProvider, recipientAddr);
if (recipInvalid) return { ...recipInvalid, properties };
const deliveryResult = await debugMessageDelivery(
core,
originDomain,
destName,
senderAddr,
recipientAddr,
senderBytes,
body,
destProvider,
);
if (deliveryResult.status && deliveryResult.details) return { ...deliveryResult, properties };
const gasEstimate = deliveryResult.gasEstimate;
const insufficientGas = await isIgpUnderfunded(core, messageId, originName, gasEstimate);
if (insufficientGas) return { ...insufficientGas, properties };
return noErrorFound(properties);
}
export async function debugExplorerMessage(
message: Message,
multiProvider = getMultiProvider(),
): Promise<Omit<MessageDebugDetails, 'properties'>> {
const {
msgId,
sender,
recipient,
originDomainId: originDomain,
destinationDomainId: destDomain,
body,
totalGasAmount,
} = message;
logger.debug(`Debugging message id: ${msgId}`);
const originName = multiProvider.getChainName(originDomain);
const destName = multiProvider.tryGetChainName(destDomain)!;
const environment = getChainEnvironment(originName);
// TODO PI support here
const core = HyperlaneCore.fromEnvironment(environment, multiProvider);
const destInvalid = isInvalidDestDomain(core, destDomain, destName);
if (destInvalid) return destInvalid;
const destProvider = multiProvider.getProvider(destName);
const recipInvalid = await isInvalidRecipient(destProvider, recipient);
if (recipInvalid) return recipInvalid;
const senderBytes = utils.addressToBytes32(sender);
const deliveryResult = await debugMessageDelivery(
core,
originDomain,
destName,
sender,
recipient,
senderBytes,
body,
destProvider,
);
if (deliveryResult.status && deliveryResult.details) return deliveryResult;
const gasEstimate = deliveryResult.gasEstimate;
const insufficientGas = await isIgpUnderfunded(
core,
msgId,
originName,
gasEstimate,
totalGasAmount,
);
if (insufficientGas) return insufficientGas;
return noErrorFound();
}
async function findTransactionDetails(txHash: string, multiProvider: MultiProvider) {
const chains = multiProvider
.getKnownChainNames()
@ -138,171 +255,209 @@ async function fetchTransactionDetails(
}
}
async function checkMessage(
core: HyperlaneCore,
multiProvider: MultiProvider,
message: DispatchedMessage,
attemptGetProcessTx = true,
): Promise<MessageDebugDetails> {
logger.debug(JSON.stringify(message));
const {
sender: senderBytes,
recipient: recipientBytes,
body,
destination,
origin,
nonce,
} = message.parsed;
const messageId = utils.messageId(message.message);
const senderAddress = utils.bytes32ToAddress(senderBytes.toString());
const recipientAddress = utils.bytes32ToAddress(recipientBytes.toString());
const properties = new Map<string, string | LinkProperty>();
properties.set('ID', messageId);
properties.set('Sender', senderAddress);
properties.set('Recipient', recipientAddress);
properties.set('Origin Domain', origin.toString());
properties.set('Origin Chain', multiProvider.tryGetChainName(origin) || 'Unknown');
properties.set('Destination Domain', destination.toString());
properties.set('Destination Chain', multiProvider.tryGetChainName(destination) || 'Unknown');
properties.set('Nonce', nonce.toString());
properties.set('Raw Bytes', message.message);
const destinationChain = multiProvider.tryGetChainName(destination);
logger.debug(`Destination chain: ${destinationChain}`);
if (!destinationChain) {
logger.info(`Unknown destination domain ${destination}`);
function isInvalidDestDomain(core: HyperlaneCore, destDomain: number, destName: string | null) {
logger.debug(`Destination chain: ${destName}`);
if (!destName) {
logger.info(`Unknown destination domain ${destDomain}`);
return {
status: MessageDebugStatus.InvalidDestDomain,
properties,
details: `No chain found for domain ${destination}. Some Domain IDs do not match Chain IDs. See https://docs.hyperlane.xyz/hyperlane-docs/developers/domains`,
details: `No chain found for domain ${destDomain}. Some Domain IDs do not match Chain IDs. See https://docs.hyperlane.xyz/docs/resources/domains`,
};
}
if (!core.knownChain(destinationChain)) {
logger.info(`Destination chain ${destinationChain} unknown for environment`);
if (!core.knownChain(destName)) {
logger.info(`Destination chain ${destName} unknown for environment`);
return {
status: MessageDebugStatus.UnknownDestChain,
properties,
details: `Hyperlane has multiple environments. See https://docs.hyperlane.xyz/hyperlane-docs/developers/domains`,
details: `Hyperlane has multiple environments. See https://docs.hyperlane.xyz/docs/resources/domains`,
};
}
return false;
}
const destinationMailbox = core.getContracts(destinationChain).mailbox.contract;
const isDelivered = await destinationMailbox.delivered(messageId);
async function isMessageAlreadyDelivered(
core: HyperlaneCore,
multiProvider: MultiProvider,
destName: string,
messageId: string,
properties: MessageDebugDetails['properties'],
) {
const destMailbox = core.getContracts(destName).mailbox.contract;
const isDelivered = await destMailbox.delivered(messageId);
if (isDelivered) {
logger.info('Message has already been processed');
if (attemptGetProcessTx) {
const processTxHash = await tryGetProcessTxHash(destinationMailbox, messageId);
if (processTxHash) {
const url = multiProvider.getExplorerTxUrl(destinationChain, { hash: processTxHash });
properties.set('Process TX', { url, text: processTxHash });
}
const processTxHash = await tryGetProcessTxHash(destMailbox, messageId);
if (processTxHash) {
const url = multiProvider.tryGetExplorerTxUrl(destName, { hash: processTxHash });
properties.set('Process TX', { url: url || 'UNKNOWN', text: processTxHash });
}
return {
status: MessageDebugStatus.AlreadyProcessed,
properties,
details: 'See delivery transaction for more details',
};
} else {
logger.debug('Message not yet processed');
}
const recipientIsContract = await isContract(multiProvider, destinationChain, recipientAddress);
logger.debug('Message not yet processed');
return false;
}
async function tryGetProcessTxHash(mailbox: Mailbox, messageId: string) {
try {
const filter = mailbox.filters.ProcessId(messageId);
const matchedEvents = await mailbox.queryFilter(filter);
if (matchedEvents?.length) {
const event = matchedEvents[0];
return event.transactionHash;
}
} catch (error) {
logger.error('Error finding process transaction', error);
}
return null;
}
async function isInvalidRecipient(provider: providers.Provider, recipient: Address) {
const recipientIsContract = await isContract(provider, recipient);
if (!recipientIsContract) {
logger.info(`Recipient address ${recipientAddress} is not a contract`);
logger.info(`Recipient address ${recipient} is not a contract`);
return {
status: MessageDebugStatus.RecipientNotContract,
properties,
details: `Recipient address is ${recipientAddress}. Ensure that the bytes32 value is not malformed.`,
details: `Recipient address is ${recipient}. Ensure that the bytes32 value is not malformed.`,
};
}
return false;
}
const destinationProvider = multiProvider.getProvider(destinationChain);
const recipientContract = IMessageRecipient__factory.connect(
recipientAddress,
destinationProvider,
);
async function isContract(provider: providers.Provider, address: Address) {
const code = await provider.getCode(address);
return code && code !== '0x'; // "Empty" code
}
async function debugMessageDelivery(
core: HyperlaneCore,
originDomain: number,
destName: string,
sender: Address,
recipient: Address,
senderBytes: string,
body: string,
destProvider: providers.Provider,
) {
const destMailbox = core.getContracts(destName).mailbox.contract;
const recipientContract = IMessageRecipient__factory.connect(recipient, destProvider);
try {
await recipientContract.estimateGas.handle(origin, senderBytes, body, {
from: destinationMailbox.address,
});
logger.debug('Calling recipient `handle` function from the inbox does not revert');
return {
status: MessageDebugStatus.NoErrorsFound,
properties,
details: 'Message may just need more time to be processed',
};
// TODO add special case for Arbitrum:
// https://github.com/hyperlane-xyz/hyperlane-monorepo/pull/1949/files#diff-79ec1cf679507919c08a9a66e0407c16fff22aee98d79cf39a0c1baf086403ebR364
const deliveryGasEst = await recipientContract.estimateGas.handle(
originDomain,
senderBytes,
body,
{
from: destMailbox.address,
},
);
logger.debug(
`Calling recipient handle function from the inbox does not revert. Gas: ${deliveryGasEst.toString()}`,
);
return { gasEstimate: deliveryGasEst.toString() };
} catch (err: any) {
logger.info('Estimate gas call failed');
const errorReason = extractReasonString(err);
logger.debug(errorReason);
const bytecodeHasHandle = await tryCheckBytecodeHandle(destinationProvider, recipientAddress);
const bytecodeHasHandle = await tryCheckBytecodeHandle(destProvider, recipient);
if (!bytecodeHasHandle) {
logger.info('Bytecode does not have function matching handle sig');
return {
status: MessageDebugStatus.RecipientNotHandler,
properties,
details: `Recipient contract should have handle function of signature: ${HANDLE_FUNCTION_SIG}. Check that recipient is not a proxy. Error: ${errorReason}`,
};
}
const icaCallError = await checkIcaMessageError(
senderAddress,
recipientAddress,
body,
origin,
destinationProvider,
);
if (icaCallError) {
const icaCallErr = await tryDebugIcaMsg(sender, recipient, body, originDomain, destProvider);
if (icaCallErr) {
return {
status: MessageDebugStatus.IcaCallFailure,
properties,
details: icaCallError,
details: icaCallErr,
};
}
return {
status: MessageDebugStatus.HandleCallFailure,
properties,
details: errorReason,
};
}
}
async function isContract(multiProvider: MultiProvider, chain: ChainName, address: string) {
const provider = multiProvider.getProvider(chain);
const code = await provider.getCode(address);
// "Empty" code
return code && code !== '0x';
async function isIgpUnderfunded(
core: HyperlaneCore,
msgId: string,
originName: string,
deliveryGasEst?: string,
totalGasAmount?: string,
) {
const igp = core.getContracts(originName).interchainGasPaymaster.contract;
const { isFunded, igpDetails } = await tryCheckIgpGasFunded(
igp,
msgId,
deliveryGasEst,
totalGasAmount,
);
if (!isFunded) {
return {
status: MessageDebugStatus.GasUnderfunded,
details: igpDetails,
};
}
return false;
}
// TODO use explorer for this instead of RPC to avoid block age limitations
// In doing so, de-dupe with features/search/useMessageProcessTx.ts
async function tryGetProcessTxHash(destinationMailbox: Mailbox, messageId: string) {
async function tryCheckIgpGasFunded(
igp: InterchainGasPaymaster,
messageId: string,
deliveryGasEst?: string,
totalGasAmount?: string,
) {
try {
const filter = destinationMailbox.filters.ProcessId(messageId);
const matchedEvents = await destinationMailbox.queryFilter(filter);
if (matchedEvents?.length) {
const event = matchedEvents[0];
return event.transactionHash;
if (!deliveryGasEst) throw new Error('No gas estimate provided');
let gasAlreadyFunded = BigNumber.from(0);
if (totalGasAmount) {
const filter = igp.filters.GasPayment(messageId, null, null);
// TODO restrict blocks here to avoid rpc errors
const matchedEvents = (await igp.queryFilter(filter)) || [];
logger.debug(`Found ${matchedEvents.length} payments to IGP for msg ${messageId}`);
logger.debug(matchedEvents);
for (const payment of matchedEvents) {
gasAlreadyFunded = gasAlreadyFunded.add(payment.args.gasAmount);
}
} else {
logger.debug(`Using totalGasAmount info from message: ${totalGasAmount}`);
gasAlreadyFunded = BigNumber.from(totalGasAmount);
}
logger.debug('Amount of gas paid for to IGP:', gasAlreadyFunded.toString());
logger.debug('Amount of gas required:', deliveryGasEst);
if (gasAlreadyFunded.lte(0)) {
return { isFunded: false, igpDetails: 'Origin IGP has not received any gas payments' };
} else if (gasAlreadyFunded.lte(deliveryGasEst)) {
return {
isFunded: false,
igpDetails: `Origin IGP gas amount is ${gasAlreadyFunded.toString()} but requires ${deliveryGasEst}`,
};
} else {
return { isFunded: true, igpDetails: '' };
}
} catch (error) {
logger.error('Error finding process transaction', error);
logger.warn('Error estimating delivery gas cost for message', error);
return { isFunded: true, igpDetails: '' };
}
return null;
}
async function tryCheckBytecodeHandle(
destinationProvider: providers.Provider,
recipientAddress: string,
) {
async function tryCheckBytecodeHandle(provider: providers.Provider, recipientAddress: string) {
try {
// scan bytecode for handle function selector
const bytecode = await destinationProvider.getCode(recipientAddress);
const bytecode = await provider.getCode(recipientAddress);
const msgRecipientInterface = IMessageRecipient__factory.createInterface();
const handleFunction = msgRecipientInterface.functions[HANDLE_FUNCTION_SIG];
const handleSignature = msgRecipientInterface.getSighash(handleFunction);
@ -313,9 +468,9 @@ async function tryCheckBytecodeHandle(
}
}
async function checkIcaMessageError(
sender: string,
recipient: string,
async function tryDebugIcaMsg(
sender: Address,
recipient: Address,
body: string,
originDomainId: number,
destinationProvider: providers.Provider,
@ -328,13 +483,13 @@ async function checkIcaMessageError(
const { sender: originalSender, calls } = decodedBody;
const icaAddress = await tryFetchIcaAddress(originDomainId, originalSender);
const icaAddress = await tryFetchIcaAddress(originDomainId, originalSender, destinationProvider);
if (!icaAddress) return null;
for (let i = 0; i < calls.length; i++) {
const call = calls[i];
logger.debug(`Checking ica call ${i + 1} of ${calls.length}`);
const errorReason = await tryCheckIcaCallError(
const errorReason = await tryCheckIcaCall(
icaAddress,
call.destinationAddress,
call.callBytes,
@ -348,7 +503,7 @@ async function checkIcaMessageError(
return null;
}
async function tryCheckIcaCallError(
async function tryCheckIcaCall(
icaAddress: string,
destinationAddress: string,
callBytes: string,
@ -381,3 +536,11 @@ function extractReasonString(rawError: any) {
return `Failure reason: ${trimToLength(errorString, 250)}`;
}
}
function noErrorFound(properties?: MessageDebugDetails['properties']): MessageDebugDetails {
return {
status: MessageDebugStatus.NoErrorsFound,
details: 'Message may just need more time to be processed',
properties: properties || new Map(),
};
}

@ -10,4 +10,5 @@ export const debugStatusToDesc: Record<MessageDebugStatus, string> = {
'Recipient bytecode is missing handle function selector',
[MessageDebugStatus.IcaCallFailure]: 'A call from the ICA account failed',
[MessageDebugStatus.HandleCallFailure]: 'Error calling handle on the recipient contract',
[MessageDebugStatus.GasUnderfunded]: 'Insufficient interchain gas has been paid for delivery',
};

@ -13,6 +13,7 @@ export enum MessageDebugStatus {
RecipientNotHandler = 'recipientNotHandler',
IcaCallFailure = 'icaCallFailure',
HandleCallFailure = 'handleCallFailure',
GasUnderfunded = 'gasUnderfunded',
}
export interface DebugNotFoundResult {

@ -1,19 +1,15 @@
import { constants } from 'ethers';
import { MultiProvider, chainIdToMetadata, hyperlaneCoreAddresses } from '@hyperlane-xyz/sdk';
import { MultiProvider, hyperlaneCoreAddresses } from '@hyperlane-xyz/sdk';
import { getMultiProvider } from '../../multiProvider';
import { Message, MessageStatus } from '../../types';
import { ensureLeading0x, validateAddress } from '../../utils/addresses';
import {
queryExplorerForLogs,
queryExplorerForTx,
queryExplorerForTxReceipt,
} from '../../utils/explorers';
import { queryExplorerForLogs, queryExplorerForTx } from '../../utils/explorers';
import { logger } from '../../utils/logger';
import { hexToDecimal } from '../../utils/number';
import { getChainEnvironment } from '../chains/utils';
import { debugMessagesForTransaction } from '../debugger/debugMessage';
import { MessageDebugStatus, TxDebugStatus } from '../debugger/types';
import { toDecimalNumber } from '../../utils/number';
import { debugExplorerMessage } from '../debugger/debugMessage';
import { MessageDebugStatus } from '../debugger/types';
import { TX_HASH_ZERO } from '../messages/placeholderMessages';
import {
MessageDeliveryFailingResult,
@ -25,18 +21,21 @@ import {
// https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/1.0.0-beta0/solidity/contracts/Mailbox.sol#L84
// https://emn178.github.io/online-tools/keccak_256.html
// Alternatively could get this by creating the Mailbox contract object via SDK
const TOPIC_0 = '0x1cae38cdd3d3919489272725a5ae62a4f48b2989b0dae843d3c279fee18073a9';
const PROCESS_TOPIC_0 = '0x1cae38cdd3d3919489272725a5ae62a4f48b2989b0dae843d3c279fee18073a9';
export async function fetchDeliveryStatus(
message: Message,
multiProvider = getMultiProvider(),
): Promise<MessageDeliveryStatusResponse> {
validateMessage(message);
const destName = multiProvider.getChainName(message.destinationChainId);
// TODO PI support here
const destMailboxAddr = hyperlaneCoreAddresses[destName]?.mailbox;
if (!destMailboxAddr) throw new Error(`No mailbox address found for dest ${destName}`);
const multiProvider = new MultiProvider();
const logs = await fetchExplorerLogsForMessage(multiProvider, message);
const logs = await fetchExplorerLogsForMessage(multiProvider, message, destMailboxAddr);
if (logs?.length) {
logger.debug(`Found delivery log for tx ${message.originTransaction.transactionHash}`);
logger.debug(`Found delivery log for tx ${message.origin.hash}`);
const log = logs[0]; // Should only be 1 log per message delivery
const txDetails = await tryFetchTransactionDetails(
multiProvider,
@ -47,71 +46,50 @@ export async function fetchDeliveryStatus(
const result: MessageDeliverySuccessResult = {
status: MessageStatus.Delivered,
deliveryTransaction: {
timestamp: toDecimalNumber(log.timeStamp) * 1000,
hash: log.transactionHash,
from: txDetails?.from || constants.AddressZero,
transactionHash: log.transactionHash,
blockNumber: hexToDecimal(log.blockNumber),
gasUsed: hexToDecimal(log.gasUsed),
timestamp: hexToDecimal(log.timeStamp) * 1000,
to: txDetails?.to || constants.AddressZero,
blockHash: txDetails?.blockHash || TX_HASH_ZERO,
blockNumber: toDecimalNumber(log.blockNumber),
mailbox: constants.AddressZero,
nonce: txDetails?.nonce || 0,
gasLimit: toDecimalNumber(txDetails?.gasLimit || 0),
gasPrice: toDecimalNumber(txDetails?.gasPrice || 0),
effectiveGasPrice: toDecimalNumber(txDetails?.gasPrice || 0),
gasUsed: toDecimalNumber(log.gasUsed),
cumulativeGasUsed: toDecimalNumber(log.gasUsed),
maxFeePerGas: toDecimalNumber(txDetails?.maxFeePerGas || 0),
maxPriorityPerGas: toDecimalNumber(txDetails?.maxPriorityFeePerGas || 0),
},
};
return result;
} else {
const { originChainId, originTransaction, nonce } = message;
const originTxHash = originTransaction.transactionHash;
const originName = chainIdToMetadata[originChainId].name;
const environment = getChainEnvironment(originName);
const originTxReceipt = await queryExplorerForTxReceipt(
multiProvider,
originChainId,
originTxHash,
);
// TODO currently throwing this over the fence to the debugger script
// which isn't very robust and uses public RPCs. Could be improved
const debugResult = await debugMessagesForTransaction(
originName,
originTxReceipt,
environment,
nonce,
false,
);
// These two cases should never happen
if (debugResult.status === TxDebugStatus.NotFound)
throw new Error('Transaction not found by debugger');
if (debugResult.status === TxDebugStatus.NoMessages)
throw new Error('No messages found for transaction');
const firstError = debugResult.messageDetails.find(
(m) =>
m.status !== MessageDebugStatus.NoErrorsFound &&
m.status !== MessageDebugStatus.AlreadyProcessed,
);
if (!firstError) {
const debugResult = await debugExplorerMessage(message, multiProvider);
if (
debugResult.status === MessageDebugStatus.NoErrorsFound ||
debugResult.status === MessageDebugStatus.AlreadyProcessed
) {
return { status: MessageStatus.Pending };
} else {
const result: MessageDeliveryFailingResult = {
status: MessageStatus.Failing,
debugStatus: firstError.status,
debugDetails: firstError.details,
debugStatus: debugResult.status,
debugDetails: debugResult.details,
};
return result;
}
}
}
async function fetchExplorerLogsForMessage(multiProvider: MultiProvider, message: Message) {
const { msgId, originChainId, originTransaction, destinationChainId } = message;
logger.debug(`Searching for delivery logs for tx ${originTransaction.transactionHash}`);
const originName = chainIdToMetadata[originChainId].name;
const destName = chainIdToMetadata[destinationChainId].name;
const destMailboxAddr = hyperlaneCoreAddresses[destName]?.mailbox;
if (!destMailboxAddr)
throw new Error(`No mailbox address found for dest ${destName} origin ${originName}`);
const topic1 = ensureLeading0x(msgId);
const logsQueryPath = `module=logs&action=getLogs&fromBlock=0&toBlock=999999999&topic0=${TOPIC_0}&topic0_1_opr=and&topic1=${topic1}&address=${destMailboxAddr}`;
function fetchExplorerLogsForMessage(
multiProvider: MultiProvider,
message: Message,
mailboxAddr: Address,
) {
const { msgId, origin, destinationChainId } = message;
logger.debug(`Searching for delivery logs for tx ${origin.hash}`);
const logsQueryPath = `module=logs&action=getLogs&fromBlock=1&toBlock=latest&topic0=${PROCESS_TOPIC_0}&topic0_1_opr=and&topic1=${msgId}&address=${mailboxAddr}`;
return queryExplorerForLogs(multiProvider, destinationChainId, logsQueryPath);
}
@ -124,35 +102,8 @@ async function tryFetchTransactionDetails(
const tx = await queryExplorerForTx(multiProvider, chainId, txHash);
return tx;
} catch (error) {
// Since we only need this for the from address, it's not critical.
// Swallowing error if there's an issue.
// Swallowing error if there's an issue so we can still surface delivery confirmation
logger.error('Failed to fetch tx details', txHash, chainId);
return null;
}
}
function validateMessage(message: Message) {
const {
originDomainId,
destinationDomainId,
originChainId,
destinationChainId,
nonce,
originTransaction,
recipient,
sender,
} = message;
if (!originDomainId) throw new Error(`Invalid origin domain ${originDomainId}`);
if (!destinationDomainId) throw new Error(`Invalid dest domain ${destinationDomainId}`);
if (!originChainId) throw new Error(`Invalid origin chain ${originChainId}`);
if (!destinationChainId) throw new Error(`Invalid dest chain ${destinationChainId}`);
if (!chainIdToMetadata[originChainId]?.name)
throw new Error(`No name found for chain ${originChainId}`);
if (!chainIdToMetadata[destinationChainId]?.name)
throw new Error(`No name found for chain ${destinationChainId}`);
if (!nonce) throw new Error(`Invalid nonce ${nonce}`);
if (!originTransaction?.transactionHash) throw new Error(`Invalid or missing origin tx`);
validateAddress(recipient, 'validateMessage recipient');
validateAddress(sender, 'validateMessage sender');
}

@ -1,4 +1,4 @@
import type { MessageStatus, PartialTransactionReceipt } from '../../types';
import type { MessageStatus, MessageTx } from '../../types';
import type { MessageDebugStatus } from '../debugger/types';
interface MessageDeliveryResult {
@ -7,7 +7,7 @@ interface MessageDeliveryResult {
export interface MessageDeliverySuccessResult extends MessageDeliveryResult {
status: MessageStatus.Delivered;
deliveryTransaction: PartialTransactionReceipt;
deliveryTransaction: MessageTx;
}
export interface MessageDeliveryFailingResult extends MessageDeliveryResult {

@ -1,46 +1,62 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { toast } from 'react-toastify';
import { Message, MessageStatus } from '../../types';
import { logger } from '../../utils/logger';
import type { MessageDeliveryStatusResponse } from './types';
import { fetchDeliveryStatus } from './fetchDeliveryStatus';
// TODO: Deprecate this to simplify message details page
export function useMessageDeliveryStatus(message: Message, isReady: boolean) {
const serializedMessage = JSON.stringify(message);
const queryResult = useQuery(
['messageProcessTx', serializedMessage, isReady],
const { data, error } = useQuery(
['messageDeliveryStatus', serializedMessage, isReady],
async () => {
// TODO enable PI support here
if (!isReady || !message || message.status === MessageStatus.Delivered || message.isPiMsg)
return null;
const response = await fetch('/api/delivery-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: serializedMessage,
});
if (!response.ok) {
const errorMsg = await response.text();
throw new Error(errorMsg);
}
const result = (await response.json()) as MessageDeliveryStatusResponse;
logger.debug('Message delivery status result', result);
return result;
logger.debug('Fetching message delivery status for:', message.id);
const deliverStatus = await fetchDeliveryStatus(message);
logger.debug('Message delivery status result', deliverStatus);
return deliverStatus;
},
{ retry: false },
);
// Show toast on error
const error = queryResult.error;
useEffect(() => {
if (error) {
logger.error('Error fetching delivery status', error);
toast.error(`${error}`);
}
}, [error]);
return queryResult;
const [messageWithDeliveryStatus, debugInfo] = useMemo(() => {
if (data?.status === MessageStatus.Delivered) {
return [
{
...message,
status: MessageStatus.Delivered,
destination: data.deliveryTransaction,
},
];
} else if (data?.status === MessageStatus.Failing) {
return [
{
...message,
status: MessageStatus.Failing,
},
{
status: data.debugStatus,
details: data.debugDetails,
originChainId: message.originChainId,
originTxHash: message.origin.hash,
},
];
}
return [message];
}, [message, data]);
return { messageWithDeliveryStatus, debugInfo };
}

@ -14,9 +14,10 @@ import { getChainDisplayName } from '../chains/utils';
import { useMessageDeliveryStatus } from '../deliveryStatus/useMessageDeliveryStatus';
import { ContentDetailsCard } from './cards/ContentDetailsCard';
import { GasDetailsCard } from './cards/GasDetailsCard';
import { IcaDetailsCard } from './cards/IcaDetailsCard';
import { TimelineCard } from './cards/TimelineCard';
import { TransactionCard, TransactionCardDebugInfo } from './cards/TransactionCard';
import { TransactionCard } from './cards/TransactionCard';
import { isIcaMessage } from './ica';
import { PLACEHOLDER_MESSAGE } from './placeholderMessages';
import { MessageIdentifierType, buildMessageQuery } from './queries/build';
@ -38,67 +39,32 @@ export function MessageDetails({ messageId, message: propMessage }: Props) {
variables,
pause: !!propMessage,
});
const messages = useMemo(() => parseMessageQueryResult(data), [data]);
const graphQueryMessages = useMemo(() => parseMessageQueryResult(data), [data]);
// Extracting message properties
const message = propMessage || messages[0] || PLACEHOLDER_MESSAGE;
const isMessageFound = !!propMessage || messages.length > 0;
const _message = propMessage || graphQueryMessages[0] || PLACEHOLDER_MESSAGE;
const isMessageFound = !!propMessage || graphQueryMessages.length > 0;
const shouldBlur = !isMessageFound;
const {
status,
originChainId,
destinationChainId: destChainId,
originTransaction,
destinationTransaction: destTransaction,
} = message;
const isIcaMsg = isIcaMessage(message);
// Message status query + resolution
const { data: deliveryStatusResponse } = useMessageDeliveryStatus(message, isMessageFound);
let resolvedDestTx = destTransaction;
let resolvedMsgStatus = status;
let debugInfo: TransactionCardDebugInfo | undefined = undefined;
// If there's a delivery status response, use those values instead
if (deliveryStatusResponse) {
resolvedMsgStatus = deliveryStatusResponse.status;
if (deliveryStatusResponse.status === MessageStatus.Delivered) {
resolvedDestTx = deliveryStatusResponse.deliveryTransaction;
} else if (deliveryStatusResponse.status === MessageStatus.Failing) {
debugInfo = {
status: deliveryStatusResponse.debugStatus,
details: deliveryStatusResponse.debugDetails,
originChainId,
originTxHash: originTransaction.transactionHash,
};
}
}
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,
);
const { status, originChainId, destinationChainId: destChainId, origin, destination } = message;
// Query re-executor
const reExecutor = useCallback(() => {
if (propMessage || (isMessageFound && resolvedMsgStatus !== MessageStatus.Delivered)) return;
if (propMessage || (isMessageFound && status === MessageStatus.Delivered)) return;
reexecuteQuery({ requestPolicy: 'network-only' });
}, [propMessage, isMessageFound, resolvedMsgStatus, reexecuteQuery]);
}, [propMessage, isMessageFound, status, reexecuteQuery]);
useInterval(reExecutor, AUTO_REFRESH_DELAY);
// Banner color setter
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)}`);
setBanner('bg-red-500');
} else if (resolvedMsgStatus === MessageStatus.Failing) {
setBanner('bg-red-500');
} else if (!isMessageFound) {
setBanner('bg-gray-500');
} else {
setBanner('');
}
}, [error, isFetching, resolvedMsgStatus, isMessageFound, setBanner]);
useEffect(() => {
return () => setBanner('');
}, [setBanner]);
useDynamicBannerColor(isFetching, status, isMessageFound, error);
return (
<>
@ -107,39 +73,33 @@ export function MessageDetails({ messageId, message: propMessage }: Props) {
isIcaMsg ? 'ICA ' : ''
} Message to ${getChainDisplayName(destChainId)}`}</h2>
<StatusHeader
messageStatus={resolvedMsgStatus}
messageStatus={status}
isMessageFound={isMessageFound}
isFetching={isFetching}
isError={!!error}
/>
</div>
<div className="flex flex-wrap items-stretch justify-between mt-5 gap-4">
<div className="flex flex-wrap items-stretch justify-between mt-5 gap-3">
<TransactionCard
title="Origin Transaction"
chainId={originChainId}
status={resolvedMsgStatus}
transaction={originTransaction}
status={status}
transaction={origin}
helpText={helpText.origin}
shouldBlur={shouldBlur}
/>
<TransactionCard
title="Destination Transaction"
chainId={destChainId}
status={resolvedMsgStatus}
transaction={resolvedDestTx}
status={status}
transaction={destination}
debugInfo={debugInfo}
helpText={helpText.destination}
shouldBlur={shouldBlur}
/>
{!message.isPiMsg && (
<TimelineCard
message={message}
resolvedStatus={resolvedMsgStatus}
resolvedDestinationTx={resolvedDestTx}
shouldBlur={shouldBlur}
/>
)}
{!message.isPiMsg && <TimelineCard message={message} shouldBlur={shouldBlur} />}
<ContentDetailsCard message={message} shouldBlur={shouldBlur} />
{!message.isPiMsg && <GasDetailsCard message={message} shouldBlur={shouldBlur} />}
{isIcaMsg && <IcaDetailsCard message={message} shouldBlur={shouldBlur} />}
</div>
</>
@ -190,6 +150,32 @@ function StatusHeader({
);
}
function useDynamicBannerColor(
isFetching: boolean,
status: MessageStatus,
isMessageFound: boolean,
error?: Error,
) {
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)}`);
setBanner('bg-red-500');
} else if (status === MessageStatus.Failing) {
setBanner('bg-red-500');
} else if (!isMessageFound) {
setBanner('bg-gray-500');
} else {
setBanner('');
}
}, [error, isFetching, status, isMessageFound, setBanner]);
useEffect(() => {
return () => setBanner('');
}, [setBanner]);
}
const helpText = {
origin: 'Info about the transaction that initiated the message placement into the outbox.',
destination:

@ -53,8 +53,8 @@ export function MessageSummaryRow({ message }: { message: MessageStub }) {
recipient,
originChainId,
destinationChainId,
originTimestamp,
destinationTimestamp,
origin,
destination,
} = message;
let statusColor = 'bg-beige-500';
@ -89,7 +89,7 @@ export function MessageSummaryRow({ message }: { message: MessageStub }) {
{shortenAddress(recipient) || 'Invalid Address'}
</LinkCell>
<LinkCell id={msgId} base64={base64} aClasses={styles.valueTruncated}>
{getHumanReadableTimeString(originTimestamp)}
{getHumanReadableTimeString(origin.timestamp)}
</LinkCell>
<LinkCell
id={msgId}
@ -97,8 +97,8 @@ export function MessageSummaryRow({ message }: { message: MessageStub }) {
tdClasses="hidden lg:table-cell text-center px-4"
aClasses={styles.valueTruncated}
>
{destinationTimestamp
? getHumanReadableDuration(destinationTimestamp - originTimestamp, 3)
{destination?.timestamp
? getHumanReadableDuration(destination.timestamp - origin.timestamp, 3)
: '-'}
</LinkCell>
<LinkCell id={msgId} base64={base64} aClasses="flex items-center justify-center">

@ -1,12 +1,13 @@
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { utils } from '@hyperlane-xyz/utils';
import { ChainToChain } from '../../../components/icons/ChainToChain';
import { HelpIcon } from '../../../components/icons/HelpIcon';
import { SelectField } from '../../../components/input/SelectField';
import { Card } from '../../../components/layout/Card';
import { MAILBOX_VERSION } from '../../../consts/environments';
import EnvelopeInfo from '../../../images/icons/envelope-info.svg';
import { Message } from '../../../types';
import { tryUtf8DecodeBytes } from '../../../utils/string';
@ -23,9 +24,7 @@ export function ContentDetailsCard({
msgId,
nonce,
originDomainId,
originChainId,
destinationDomainId,
destinationChainId,
sender,
recipient,
body,
@ -59,9 +58,7 @@ export function ContentDetailsCard({
return (
<Card classes="w-full space-y-4">
<div className="flex items-center justify-between">
<div className="relative -top-px -left-0.5">
<ChainToChain originChainId={originChainId} destinationChainId={destinationChainId} />
</div>
<Image src={EnvelopeInfo} width={28} height={28} alt="" className="opacity-80" />
<div className="flex items-center pb-1">
<h3 className="text-gray-500 font-medium text-md mr-2">Message Details</h3>
<HelpIcon
@ -70,36 +67,38 @@ export function ContentDetailsCard({
/>
</div>
</div>
<KeyValueRow
label="Message Id:"
labelWidth="w-20"
display={msgId}
displayWidth="w-60 sm:w-80"
showCopy={true}
blurValue={shouldBlur}
/>
<KeyValueRow
label="Sender:"
labelWidth="w-20"
display={sender}
displayWidth="w-60 sm:w-80"
showCopy={true}
blurValue={shouldBlur}
/>
<KeyValueRow
label="Recipient:"
labelWidth="w-20"
display={recipient}
displayWidth="w-60 sm:w-80"
showCopy={true}
blurValue={shouldBlur}
/>
<KeyValueRow
label="Nonce:"
labelWidth="w-20"
display={nonce.toString()}
blurValue={shouldBlur}
/>
<div className="flex flex-wrap gap-x-6 gap-y-4">
<KeyValueRow
label="Identifer:"
labelWidth="w-16"
display={msgId}
displayWidth="w-64 sm:w-80"
showCopy={true}
blurValue={shouldBlur}
/>
<KeyValueRow
label="Nonce:"
labelWidth="w-16"
display={nonce.toString()}
blurValue={shouldBlur}
/>
<KeyValueRow
label="Sender:"
labelWidth="w-16"
display={sender}
displayWidth="w-64 sm:w-80"
showCopy={true}
blurValue={shouldBlur}
/>
<KeyValueRow
label="Recipient:"
labelWidth="w-16"
display={recipient}
displayWidth="w-64 sm:w-80"
showCopy={true}
blurValue={shouldBlur}
/>
</div>
<div>
<div className="flex items-center">
<label className="text-sm text-gray-500">Message Content:</label>

@ -0,0 +1,111 @@
import BigNumber from 'bignumber.js';
import { utils } from 'ethers';
import Image from 'next/image';
import { useState } from 'react';
import { RadioButtons } from '../../../components/buttons/RadioButtons';
import { HelpIcon } from '../../../components/icons/HelpIcon';
import { Card } from '../../../components/layout/Card';
import { links } from '../../../consts/links';
import FuelPump from '../../../images/icons/fuel-pump.svg';
import { Message } from '../../../types';
import { fromWei } from '../../../utils/amount';
import { logger } from '../../../utils/logger';
import { KeyValueRow } from './KeyValueRow';
interface Props {
message: Message;
shouldBlur: boolean;
}
const unitOptions = [
{ value: 'ether', display: 'Eth' },
{ value: 'gwei', display: 'Gwei' },
{ value: 'wei', display: 'Wei' },
];
export function GasDetailsCard({ message, shouldBlur }: Props) {
const [unit, setUnit] = useState(unitOptions[0].value);
const { totalGasAmount, totalPayment: totalPaymentWei, numPayments } = message;
const paymentFormatted = fromWei(totalPaymentWei, unit).toString();
const avgPrice = computeAvgGasPrice(unit, totalGasAmount, totalPaymentWei);
return (
<Card classes="w-full space-y-4 relative">
<div className="flex items-center justify-between">
<Image src={FuelPump} width={24} height={24} alt="" className="opacity-80" />
<div className="flex items-center pb-1">
<h3 className="text-gray-500 font-medium text-md mr-2">Interchain Gas Payments</h3>
<HelpIcon
size={16}
text="Amounts paid to the Interchain Gas Paymaster for message delivery."
/>
</div>
</div>
<p className="text-sm">
Interchain gas payments are required to fund message delivery on the destination chain.{' '}
<a
href={`${links.docs}/docs/build-with-hyperlane/guides/paying-for-interchain-gas`}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer text-blue-500 hover:text-blue-400 active:text-blue-300 transition-all"
>
Learn more about gas on Hyperlane.
</a>
</p>
<div className="flex flex-wrap gap-x-4 gap-y-4 mr-36">
<KeyValueRow
label="Payment count:"
labelWidth="w-28"
display={numPayments?.toString() || '0'}
blurValue={shouldBlur}
classes="basis-5/12"
/>
<KeyValueRow
label="Total gas amount:"
labelWidth="w-28"
display={totalGasAmount?.toString() || '0'}
blurValue={shouldBlur}
classes="basis-5/12"
/>
<KeyValueRow
label="Total paid:"
labelWidth="w-28"
display={totalPaymentWei ? paymentFormatted : '0'}
blurValue={shouldBlur}
classes="basis-5/12"
/>
<KeyValueRow
label="Average price:"
labelWidth="w-28"
display={avgPrice ? avgPrice.formatted : '-'}
blurValue={shouldBlur}
classes="basis-5/12"
/>
</div>
<div className="absolute right-2 bottom-2">
<RadioButtons
options={unitOptions}
selected={unit}
onChange={(value) => setUnit(value)}
label="Gas unit"
/>
</div>
</Card>
);
}
function computeAvgGasPrice(unit: string, gasAmount?: string, payment?: string) {
try {
if (!gasAmount || !payment) return null;
const paymentBN = new BigNumber(payment);
const wei = paymentBN.div(gasAmount).toFixed(0);
const formatted = utils.formatUnits(wei, unit).toString();
return { wei, formatted };
} catch (error) {
logger.debug('Error computing avg gas price', error);
return null;
}
}

@ -1,8 +1,9 @@
import Image from 'next/image';
import { useMemo } from 'react';
import { HelpIcon } from '../../../components/icons/HelpIcon';
import { InterchainAccount } from '../../../components/icons/InterchainAccount';
import { Card } from '../../../components/layout/Card';
import AccountStar from '../../../images/icons/account-star.svg';
import { Message } from '../../../types';
import { tryDecodeIcaBody, useIcaAddress } from '../ica';
@ -26,7 +27,7 @@ export function IcaDetailsCard({ message: { originDomainId, body }, shouldBlur }
<Card classes="w-full space-y-4">
<div className="flex items-center justify-between">
<div className="relative -top-px -left-0.5">
<InterchainAccount />
<Image src={AccountStar} width={28} height={28} alt="" className="opacity-80" />
</div>
<div className="flex items-center pb-1">
<h3 className="text-gray-500 font-medium text-md mr-2">ICA Details</h3>

@ -8,6 +8,7 @@ interface Props {
subDisplay?: string;
showCopy?: boolean;
blurValue?: boolean;
classes?: string;
}
export function KeyValueRow({
@ -18,15 +19,16 @@ export function KeyValueRow({
subDisplay,
showCopy,
blurValue,
classes,
}: Props) {
return (
<div className="flex items-center pl-px">
<div className={`flex items-center pl-px ${classes}`}>
<label className={`text-sm text-gray-500 ${labelWidth}`}>{label}</label>
<div className={`text-sm ml-2 truncate ${displayWidth || ''} ${blurValue && 'blur-xs'}`}>
<div className={`text-sm ml-1 truncate ${displayWidth || ''} ${blurValue && 'blur-xs'}`}>
<span>{display}</span>
{subDisplay && <span className="text-xs ml-2">{subDisplay}</span>}
</div>
{showCopy && <CopyButton copyValue={display} width={13} height={13} classes="ml-3" />}
{showCopy && <CopyButton copyValue={display} width={13} height={13} classes="ml-1" />}
</div>
);
}

@ -3,22 +3,32 @@ import { useMemo } from 'react';
import { MessageTimeline, useMessageStage } from '@hyperlane-xyz/widgets';
import { Card } from '../../../components/layout/Card';
import { Message, MessageStatus, PartialTransactionReceipt } from '../../../types';
import { Message } from '../../../types';
interface Props {
message: Message;
resolvedStatus: MessageStatus;
resolvedDestinationTx?: PartialTransactionReceipt;
shouldBlur?: boolean;
}
export function TimelineCard({ message, resolvedStatus, resolvedDestinationTx }: Props) {
const resolvedMessage = useMemo(
() => ({ ...message, status: resolvedStatus, destinationTransaction: resolvedDestinationTx }),
[message, resolvedStatus, resolvedDestinationTx],
export function TimelineCard({ message }: Props) {
// TODO update Timeline widget schema to newer message shape so this x-form is not needed
const partialMessage = useMemo(
() => ({
...message,
originTransaction: {
blockNumber: message.origin.blockNumber,
timestamp: message.origin.timestamp,
},
destinationTransaction: message.destination
? {
blockNumber: message.destination.blockNumber,
timestamp: message.destination.timestamp,
}
: undefined,
}),
[message],
);
const { stage, timings } = useMessageStage({ message });
const { stage, timings } = useMessageStage({ message: partialMessage });
return (
<Card classes="w-full">
@ -26,8 +36,8 @@ export function TimelineCard({ message, resolvedStatus, resolvedDestinationTx }:
<h3 className="text-gray-500 font-medium text-md mr-2">Delivery Timeline</h3>
<HelpIcon size={16} text="A breakdown of the stages for delivering a message" />
</div> */}
<div className="sm:px-2">
<MessageTimeline status={resolvedMessage.status} stage={stage} timings={timings} />
<div className="-mx-2 sm:mx-0 -my-2">
<MessageTimeline status={message.status} stage={stage} timings={timings} />
</div>
</Card>
);

@ -6,7 +6,7 @@ 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 { MessageStatus, PartialTransactionReceipt } from '../../../types';
import { MessageStatus, MessageTx } from '../../../types';
import { getDateTimeString, getHumanReadableTimeString } from '../../../utils/time';
import { getChainDisplayName } from '../../chains/utils';
import { debugStatusToDesc } from '../../debugger/strings';
@ -18,7 +18,7 @@ interface TransactionCardProps {
title: string;
chainId: number;
status: MessageStatus;
transaction?: PartialTransactionReceipt;
transaction?: MessageTx;
debugInfo?: TransactionCardDebugInfo;
helpText: string;
shouldBlur: boolean;
@ -40,10 +40,10 @@ export function TransactionCard({
helpText,
shouldBlur,
}: TransactionCardProps) {
const hash = transaction?.transactionHash;
const hash = transaction?.hash;
const txExplorerLink = hash ? getMultiProvider().tryGetExplorerTxUrl(chainId, { hash }) : null;
return (
<Card classes="flex-1 min-w-fit space-y-4">
<Card classes="flex-1 min-w-fit space-y-3">
<div className="flex items-center justify-between">
<div className="relative -top-px -left-0.5">
<ChainLogo chainId={chainId} />
@ -65,7 +65,7 @@ export function TransactionCard({
<KeyValueRow
label="Tx hash:"
labelWidth="w-16"
display={transaction.transactionHash}
display={transaction.hash}
displayWidth="w-60 sm:w-64"
showCopy={true}
blurValue={shouldBlur}
@ -107,15 +107,15 @@ export function TransactionCard({
)}
{!transaction && status === MessageStatus.Failing && (
<div className="flex flex-col items-center py-5">
<div className="text-gray-500 text-center">
<div className="text-gray-700 text-center">
Destination delivery transaction currently failing
</div>
{debugInfo && (
<>
<div className="mt-4 text-gray-500 text-center">
<div className="mt-4 text-gray-700 text-center">
{debugStatusToDesc[debugInfo.status]}
</div>
<div className="mt-4 text-gray-500 text-sm max-w-sm text-center break-words">
<div className="mt-4 text-gray-700 text-sm max-w-sm text-center break-words">
{debugInfo.details}
</div>
</>

@ -1,9 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { BigNumber, utils } from 'ethers';
import { BigNumber, providers, utils } from 'ethers';
import { InterchainAccountRouter__factory } from '@hyperlane-xyz/core';
import { MultiProvider, hyperlaneCoreAddresses } from '@hyperlane-xyz/sdk';
import { hyperlaneCoreAddresses } from '@hyperlane-xyz/sdk';
import { getMultiProvider } from '../../multiProvider';
import { areAddressesEqual, isValidAddress } from '../../utils/addresses';
import { logger } from '../../utils/logger';
@ -14,8 +15,8 @@ export function isIcaMessage({
sender,
recipient,
}: {
sender: string;
recipient: string;
sender: Address;
recipient: Address;
hash?: string;
}) {
const isSenderIca = isAddressIcaRouter(sender);
@ -30,7 +31,8 @@ export function isIcaMessage({
return false;
}
function isAddressIcaRouter(addr: string) {
function isAddressIcaRouter(addr: Address) {
// TODO PI support
return ICA_ADDRESS && areAddressesEqual(addr, ICA_ADDRESS);
}
@ -68,18 +70,19 @@ export function tryDecodeIcaBody(body: string) {
}
}
// TODO do this on backend and use private RPC
export async function tryFetchIcaAddress(originDomainId: number, senderAddress: string) {
export async function tryFetchIcaAddress(
originDomainId: number,
sender: Address,
provider: providers.Provider,
) {
try {
if (!ICA_ADDRESS) return null;
logger.debug('Fetching Ica address', originDomainId, senderAddress);
// TODO improved PI support
const multiProvider = new MultiProvider();
const provider = multiProvider.getProvider(originDomainId);
logger.debug('Fetching Ica address', originDomainId, sender);
const icaContract = InterchainAccountRouter__factory.connect(ICA_ADDRESS, provider);
const icaAddress = await icaContract['getInterchainAccount(uint32,address)'](
originDomainId,
senderAddress,
sender,
);
if (!isValidAddress(icaAddress)) throw new Error(`Invalid Ica addr ${icaAddress}`);
logger.debug('Ica address found', icaAddress);
@ -90,12 +93,13 @@ export async function tryFetchIcaAddress(originDomainId: number, senderAddress:
}
}
export function useIcaAddress(originDomainId: number, senderAddress?: string | null) {
export function useIcaAddress(originDomainId: number, sender?: Address | null) {
return useQuery(
['messageIcaAddress', originDomainId, senderAddress],
['messageIcaAddress', originDomainId, sender],
() => {
if (!originDomainId || !senderAddress || BigNumber.from(senderAddress).isZero()) return null;
return tryFetchIcaAddress(originDomainId, senderAddress);
if (!originDomainId || !sender || BigNumber.from(sender).isZero()) return null;
const provider = getMultiProvider().getProvider(originDomainId);
return tryFetchIcaAddress(originDomainId, sender, provider);
},
{ retry: false },
);

@ -1,15 +1,25 @@
import { constants } from 'ethers';
import { Message, MessageStatus, PartialTransactionReceipt } from '../../types';
import { Message, MessageStatus, MessageTx } from '../../types';
const TX_HASH_ZERO = '0x0000000000000000000000000000000000000000000000000000000000000000';
export const TX_HASH_ZERO = '0x0000000000000000000000000000000000000000000000000000000000000000';
export const TX_ZERO: PartialTransactionReceipt = {
export const TX_ZERO: MessageTx = {
timestamp: Date.now(),
hash: TX_HASH_ZERO,
from: constants.AddressZero,
transactionHash: TX_HASH_ZERO,
to: constants.AddressZero,
blockHash: TX_HASH_ZERO,
blockNumber: 123456789,
mailbox: constants.AddressZero,
nonce: 0,
gasLimit: 100_000,
gasPrice: 100,
effectiveGasPrice: 100,
gasUsed: 100_000,
timestamp: Date.now(),
cumulativeGasUsed: 100_000,
maxFeePerGas: 100,
maxPriorityPerGas: 100,
};
const BODY_ZERO =
@ -17,7 +27,7 @@ const BODY_ZERO =
export const PLACEHOLDER_MESSAGE: Message = {
id: '1',
msgId: TX_HASH_ZERO.substring(2),
msgId: TX_HASH_ZERO,
nonce: 1,
status: MessageStatus.Pending,
sender: constants.AddressZero,
@ -27,8 +37,8 @@ export const PLACEHOLDER_MESSAGE: Message = {
destinationDomainId: 0,
originChainId: 0,
destinationChainId: 0,
originTimestamp: Date.now(),
destinationTimestamp: Date.now(),
originTransaction: TX_ZERO,
destinationTransaction: TX_ZERO,
origin: TX_ZERO,
destination: TX_ZERO,
totalGasAmount: '100000',
totalPayment: '100000',
};

@ -1,6 +1,6 @@
import { trimLeading0x } from '../../../utils/addresses';
import { adjustToUtcTime } from '../../../utils/time';
import { stringToPostgresBytea } from './encoding';
import { messageDetailsFragment, messageStubFragment } from './fragments';
/**
@ -31,25 +31,25 @@ export function buildMessageQuery(
if (idType === MessageIdentifierType.Id) {
whereClause = 'msg_id: {_eq: $identifier}';
} else if (idType === MessageIdentifierType.Sender) {
whereClause = 'sender: {_ilike: $identifier}';
whereClause = 'sender: {_eq: $identifier}';
} else if (idType === MessageIdentifierType.Recipient) {
whereClause = 'recipient: {_ilike: $identifier}';
whereClause = 'recipient: {_eq: $identifier}';
} else if (idType === MessageIdentifierType.OriginTxHash) {
whereClause = 'transaction: {hash: {_ilike: $identifier}}';
whereClause = 'origin_tx_hash: {_eq: $identifier}';
} else if (idType === MessageIdentifierType.OriginTxSender) {
whereClause = 'transaction: {sender: {_ilike: $identifier}}';
whereClause = 'origin_tx_sender: {_eq: $identifier}';
} else if (idType === MessageIdentifierType.DestinationTxHash) {
whereClause = 'delivered_message: {transaction: {hash: {_ilike: $identifier}}}';
whereClause = 'destination_tx_hash: {_eq: $identifier}';
} else if (idType === MessageIdentifierType.DestinationTxSender) {
whereClause = 'delivered_message: {transaction: {sender: {_ilike: $identifier}}}';
whereClause = 'destination_tx_sender: {_eq: $identifier}';
} else {
throw new Error(`Invalid id type: ${idType}`);
}
const variables = { identifier: trimLeading0x(idValue) };
const variables = { identifier: stringToPostgresBytea(idValue) };
const query = `
query ($identifier: String!){
message(
query ($identifier: bytea!){
message_view(
where: {${whereClause}},
limit: ${limit}
) {
@ -65,10 +65,10 @@ const searchWhereClause = `
{msg_id: {_eq: $search}},
{sender: {_eq: $search}},
{recipient: {_eq: $search}},
{transaction: {hash: {_eq: $search}}},
{transaction: {sender: {_eq: $search}}},
{delivered_message: {transaction: {hash: {_eq: $search}}}},
{delivered_message: {transaction: {sender: {_eq: $search}}}}
{origin_tx_hash: {_eq: $search}},
{origin_tx_sender: {_eq: $search}},
{destination_tx_hash: {_eq: $search}},
{destination_tx_sender: {_eq: $search}},
]}
`;
@ -88,7 +88,7 @@ export function buildMessageSearchQuery(
const startTime = startTimeFilter ? adjustToUtcTime(startTimeFilter) : undefined;
const endTime = endTimeFilter ? adjustToUtcTime(endTimeFilter) : undefined;
const variables = {
search: hasInput ? trimLeading0x(searchInput) : undefined,
search: hasInput ? stringToPostgresBytea(searchInput) : undefined,
originChains,
destinationChains,
startTime,
@ -96,18 +96,18 @@ export function buildMessageSearchQuery(
};
const query = `
query ($search: String, $originChains: [Int!], $destinationChains: [Int!], $startTime: timestamp, $endTime: timestamp) {
message(
query ($search: bytea, $originChains: [bigint!], $destinationChains: [bigint!], $startTime: timestamp, $endTime: timestamp) {
message_view(
where: {
_and: [
${originFilter ? '{origin: {_in: $originChains}},' : ''}
${destFilter ? '{destination: {_in: $destinationChains}},' : ''}
${startTimeFilter ? '{timestamp: {_gte: $startTime}},' : ''}
${endTimeFilter ? '{timestamp: {_lte: $endTime}},' : ''}
${originFilter ? '{origin_chain_id: {_in: $originChains}},' : ''}
${destFilter ? '{destination_chain_id: {_in: $destinationChains}},' : ''}
${startTimeFilter ? '{send_occurred_at: {_gte: $startTime}},' : ''}
${endTimeFilter ? '{send_occurred_at: {_lte: $endTime}},' : ''}
${hasInput ? searchWhereClause : ''}
]
},
order_by: {timestamp: desc},
order_by: {send_occurred_at: desc},
limit: ${limit}
) {
${useStub ? messageStubFragment : messageDetailsFragment}

@ -0,0 +1,12 @@
import { ensureLeading0x, trimLeading0x } from '../../../utils/addresses';
export function stringToPostgresBytea(hexString: string) {
const trimmed = trimLeading0x(hexString).toLowerCase();
const prefix = `\\x`;
return `${prefix}${trimmed}`;
}
export function postgresByteaToString(byteString: string) {
if (!byteString || byteString.length < 4) throw new Error('Invalid byte string');
return ensureLeading0x(byteString.substring(2));
}

@ -7,84 +7,57 @@
export const messageStubFragment = `
id
msg_id
destination
origin
recipient
nonce
sender
timestamp
delivered_message {
id
tx_id
destination_mailbox
transaction {
block {
timestamp
}
}
}
message_states {
block_height
block_timestamp
error_msg
estimated_gas_cost
id
processable
}
recipient
is_delivered
send_occurred_at
delivery_occurred_at
delivery_latency
origin_chain_id
origin_domain_id
origin_tx_id
origin_tx_hash
origin_tx_sender
destination_chain_id
destination_domain_id
destination_tx_id
destination_tx_hash
destination_tx_sender
`;
export const messageDetailsFragment = `
destination
id
msg_id
nonce
msg_body
origin
origin_tx_id
${messageStubFragment}
message_body
origin_block_hash
origin_block_height
origin_block_id
origin_mailbox
recipient
sender
timestamp
transaction {
id
block_id
gas_used
hash
sender
block {
hash
domain
height
id
timestamp
}
}
delivered_message {
id
tx_id
destination_mailbox
transaction {
block_id
gas_used
hash
id
sender
block {
domain
hash
height
id
timestamp
}
}
}
message_states {
block_height
block_timestamp
error_msg
estimated_gas_cost
id
processable
}
origin_tx_cumulative_gas_used
origin_tx_effective_gas_price
origin_tx_gas_limit
origin_tx_gas_price
origin_tx_gas_used
origin_tx_max_fee_per_gas
origin_tx_max_priority_fee_per_gas
origin_tx_nonce
origin_tx_recipient
destination_block_hash
destination_block_height
destination_block_id
destination_mailbox
destination_tx_cumulative_gas_used
destination_tx_effective_gas_price
destination_tx_gas_limit
destination_tx_gas_price
destination_tx_gas_used
destination_tx_max_fee_per_gas
destination_tx_max_priority_fee_per_gas
destination_tx_nonce
destination_tx_recipient
total_gas_amount
total_payment
num_payments
`;
/**
@ -93,73 +66,65 @@ export const messageDetailsFragment = `
* Must correspond with fragments above
* ====================================
*/
export interface BlockEntry {
id: string;
hash: string; // binary e.g. \\x123
domain: number;
height: number;
timestamp: string; // e.g. "2022-08-28T17:30:15"
}
export interface TransactionEntry {
id: number;
block_id: number;
gas_used: number;
hash: string; // binary e.g. \\x123
sender: string; // binary e.g. \\x123
block: BlockEntry;
}
export interface DeliveredMessageStubEntry {
id: number;
tx_id: number;
destination_mailbox: string;
transaction: {
block: {
timestamp: string; // e.g. "2022-08-28T17:30:15"
};
};
}
export interface DeliveredMessageEntry extends DeliveredMessageStubEntry {
transaction: TransactionEntry;
}
export interface MessageStateEntry {
id: number;
block_height: number;
block_timestamp: string; // e.g. "2022-08-28T17:30:15",
error_msg: string | null | undefined;
estimated_gas_cost: number;
processable: boolean;
}
export interface MessageStubEntry {
id: number;
msg_id: string;
destination: number;
origin: number;
recipient: string; // binary e.g. \\x123
id: number; // database id, not message id
msg_id: string; // binary e.g. \\x123
nonce: number;
sender: string; // binary e.g. \\x123
timestamp: string; // e.g. "2022-08-28T17:30:15"
delivered_message: DeliveredMessageStubEntry | null | undefined;
message_states: MessageStateEntry[];
recipient: string; // binary e.g. \\x123
is_delivered: boolean;
send_occurred_at: string; // e.g. "2022-08-28T17:30:15"
delivery_occurred_at: string | null; // e.g. "2022-08-28T17:30:15"
delivery_latency: string | null; // e.g. "00:00:32"
origin_chain_id: number;
origin_domain_id: number;
origin_tx_id: number; // database id
origin_tx_hash: string; // binary e.g. \\x123
origin_tx_sender: string; // binary e.g. \\x123
destination_chain_id: number;
destination_domain_id: number;
destination_tx_id: number | null; // database id
destination_tx_hash: string | null; // binary e.g. \\x123
destination_tx_sender: string | null; // binary e.g. \\x123
}
export interface MessageEntry extends MessageStubEntry {
nonce: number;
msg_body: string | null | undefined; // binary e.g. \\x123
origin_mailbox: string;
origin_tx_id: number;
transaction: TransactionEntry; // origin transaction
delivered_message: DeliveredMessageEntry | null | undefined;
message_body: string | null; // binary e.g. \\x123
origin_block_hash: string; // binary e.g. \\x123
origin_block_height: number;
origin_block_id: number; // database id
origin_mailbox: string; // binary e.g. \\x123
origin_tx_cumulative_gas_used: number;
origin_tx_effective_gas_price: number;
origin_tx_gas_limit: number;
origin_tx_gas_price: number;
origin_tx_gas_used: number;
origin_tx_max_fee_per_gas: number;
origin_tx_max_priority_fee_per_gas: number;
origin_tx_nonce: number;
origin_tx_recipient: string; // binary e.g. \\x123
destination_block_hash: string | null; // binary e.g. \\x123
destination_block_height: number | null;
destination_block_id: number | null; // database id
destination_mailbox: string; // binary e.g. \\x123
destination_tx_cumulative_gas_used: number | null;
destination_tx_effective_gas_price: number | null;
destination_tx_gas_limit: number | null;
destination_tx_gas_price: number | null;
destination_tx_gas_used: number | null;
destination_tx_max_fee_per_gas: number | null;
destination_tx_max_priority_fee_per_gas: number | null;
destination_tx_nonce: number | null;
destination_tx_recipient: string; // binary e.g. \\x123
total_gas_amount: number;
total_payment: number;
num_payments: number;
}
export interface MessagesStubQueryResult {
message: MessageStubEntry[];
message_view: MessageStubEntry[];
}
export interface MessagesQueryResult {
message: MessageEntry[];
message_view: MessageEntry[];
}

@ -1,15 +1,13 @@
import { TEST_RECIPIENT_ADDRESS } from '../../../consts/addresses';
import { Message, MessageStatus, MessageStub, PartialTransactionReceipt } from '../../../types';
import { areAddressesEqual, ensureLeading0x } from '../../../utils/addresses';
import { Message, MessageStatus, MessageStub } from '../../../types';
import { logger } from '../../../utils/logger';
import { tryUtf8DecodeBytes } from '../../../utils/string';
import { postgresByteaToString } from './encoding';
import {
MessageEntry,
MessageStubEntry,
MessagesQueryResult,
MessagesStubQueryResult,
TransactionEntry,
} from './fragments';
/**
@ -20,33 +18,40 @@ import {
*/
export function parseMessageStubResult(data: MessagesStubQueryResult | undefined): MessageStub[] {
if (!data?.message?.length) return [];
return data.message.map(parseMessageStub).filter((m): m is MessageStub => !!m);
if (!data?.message_view?.length) return [];
return data.message_view.map(parseMessageStub).filter((m): m is MessageStub => !!m);
}
export function parseMessageQueryResult(data: MessagesQueryResult | undefined): Message[] {
if (!data?.message?.length) return [];
return data.message.map(parseMessage).filter((m): m is Message => !!m);
if (!data?.message_view?.length) return [];
return data.message_view.map(parseMessage).filter((m): m is Message => !!m);
}
function parseMessageStub(m: MessageStubEntry): MessageStub | null {
try {
const status = getMessageStatus(m);
const destinationTimestamp = m.delivered_message?.transaction
? parseTimestampString(m.delivered_message.transaction.block.timestamp)
: undefined;
return {
status: getMessageStatus(m),
id: m.id.toString(),
msgId: ensureLeading0x(m.msg_id),
status,
sender: parsePaddedAddress(m.sender),
recipient: parsePaddedAddress(m.recipient),
originDomainId: m.origin,
destinationDomainId: m.destination,
originChainId: m.origin,
destinationChainId: m.destination,
originTimestamp: parseTimestampString(m.timestamp),
destinationTimestamp,
msgId: postgresByteaToString(m.msg_id),
nonce: m.nonce,
sender: postgresByteaToString(m.sender),
recipient: postgresByteaToString(m.recipient),
originChainId: m.origin_chain_id,
originDomainId: m.origin_domain_id,
destinationChainId: m.destination_chain_id,
destinationDomainId: m.destination_domain_id,
origin: {
timestamp: parseTimestampString(m.send_occurred_at),
hash: postgresByteaToString(m.origin_tx_hash),
from: postgresByteaToString(m.origin_tx_sender),
},
destination: m.is_delivered
? {
timestamp: parseTimestampString(m.delivery_occurred_at!),
hash: postgresByteaToString(m.destination_tx_hash!),
from: postgresByteaToString(m.destination_tx_sender!),
}
: undefined,
};
} catch (error) {
logger.error('Error parsing message stub', error);
@ -59,21 +64,48 @@ function parseMessage(m: MessageEntry): Message | null {
const stub = parseMessageStub(m);
if (!stub) throw new Error('Message stub required');
const destinationTransaction = m.delivered_message?.transaction
? parseTransaction(m.delivered_message.transaction)
: undefined;
const body = decodePostgresBinaryHex(m.msg_body ?? '');
const isTestRecipient = areAddressesEqual(stub.recipient, TEST_RECIPIENT_ADDRESS);
const decodedBody = isTestRecipient ? tryUtf8DecodeBytes(body) : undefined;
const body = postgresByteaToString(m.message_body ?? '');
const decodedBody = tryUtf8DecodeBytes(body);
return {
...stub,
nonce: m.nonce,
body,
decodedBody,
originTransaction: parseTransaction(m.transaction),
destinationTransaction,
origin: {
...stub.origin,
blockHash: postgresByteaToString(m.origin_block_hash),
blockNumber: m.origin_block_height,
mailbox: postgresByteaToString(m.origin_mailbox),
nonce: m.origin_tx_nonce,
to: postgresByteaToString(m.origin_tx_recipient),
gasLimit: m.origin_tx_gas_limit,
gasPrice: m.origin_tx_gas_price,
effectiveGasPrice: m.origin_tx_effective_gas_price,
gasUsed: m.origin_tx_gas_used,
cumulativeGasUsed: m.origin_tx_cumulative_gas_used,
maxFeePerGas: m.origin_tx_max_fee_per_gas,
maxPriorityPerGas: m.origin_tx_max_priority_fee_per_gas,
},
destination: stub.destination
? {
...stub.destination,
blockHash: postgresByteaToString(m.destination_block_hash!),
blockNumber: m.destination_block_height!,
mailbox: postgresByteaToString(m.destination_mailbox!),
nonce: m.destination_tx_nonce!,
to: postgresByteaToString(m.destination_tx_recipient!),
gasLimit: m.destination_tx_gas_limit!,
gasPrice: m.destination_tx_gas_price!,
effectiveGasPrice: m.destination_tx_effective_gas_price!,
gasUsed: m.destination_tx_gas_used!,
cumulativeGasUsed: m.destination_tx_cumulative_gas_used!,
maxFeePerGas: m.destination_tx_max_fee_per_gas!,
maxPriorityPerGas: m.destination_tx_max_priority_fee_per_gas!,
}
: undefined,
totalGasAmount: m.total_gas_amount.toString(),
totalPayment: m.total_payment.toString(),
numPayments: m.num_payments,
};
} catch (error) {
logger.error('Error parsing message', error);
@ -81,41 +113,16 @@ function parseMessage(m: MessageEntry): Message | null {
}
}
function parseTransaction(t: TransactionEntry): PartialTransactionReceipt {
return {
from: ensureLeading0x(t.sender),
transactionHash: ensureLeading0x(t.hash),
blockNumber: t.block.height,
gasUsed: t.gas_used,
timestamp: parseTimestampString(t.block.timestamp),
};
}
function parseTimestampString(t: string) {
const asUtc = t.at(-1) === 'Z' ? t : t + 'Z';
return new Date(asUtc).getTime();
}
function parsePaddedAddress(a: string) {
if (!a || a.length < 40) return '';
return ensureLeading0x(a.slice(-40));
}
// https://github.com/bendrucker/postgres-bytea/blob/master/decoder.js
function decodePostgresBinaryHex(b: string) {
const buffer = Buffer.from(b.substring(2), 'hex');
return ensureLeading0x(buffer.toString('hex'));
}
function getMessageStatus(m: MessageEntry | MessageStubEntry) {
const { delivered_message, message_states } = m;
if (delivered_message) {
if (m.is_delivered) {
return MessageStatus.Delivered;
} else if (message_states.length > 0) {
const latestState = message_states.at(-1);
if (latestState && !latestState.processable) {
return MessageStatus.Failing;
}
} else {
// TODO consider gas and failure conditions here
return MessageStatus.Pending;
}
return MessageStatus.Pending;
}

@ -1,7 +0,0 @@
import { providers } from 'ethers';
export interface LogWithTimestamp extends providers.Log {
timestamp: number;
from?: Address;
to?: Address;
}

@ -7,9 +7,9 @@ import { buildMessageSearchQuery } from '../queries/build';
import { MessagesStubQueryResult } from '../queries/fragments';
import { parseMessageStubResult } from '../queries/parse';
const AUTO_REFRESH_DELAY = 10000;
const AUTO_REFRESH_DELAY = 15000;
const LATEST_QUERY_LIMIT = 12;
const SEARCH_QUERY_LIMIT = 40;
const SEARCH_QUERY_LIMIT = 50;
export function isValidSearchQuery(input: string, allowAddress?: boolean) {
if (!input) return false;

@ -7,7 +7,7 @@ import { utils } from '@hyperlane-xyz/utils';
import { getMultiProvider } from '../../../multiProvider';
import { useStore } from '../../../store';
import { Message, MessageStatus, PartialTransactionReceipt } from '../../../types';
import { LogWithTimestamp, Message, MessageStatus } from '../../../types';
import {
ensureLeading0x,
isValidAddress,
@ -23,7 +23,6 @@ import {
import { logger } from '../../../utils/logger';
import { ChainConfig } from '../../chains/chainConfig';
import { LogWithTimestamp } from './types';
import { isValidSearchQuery } from './useMessageQuery';
const PROVIDER_LOGS_BLOCK_WINDOW = 100_000;
@ -49,7 +48,7 @@ export function usePiChainMessageQuery(
async () => {
const hasInput = !!sanitizedInput;
const isValidInput = isValidSearchQuery(sanitizedInput, true);
if (pause || !hasInput || !isValidInput || !Object.keys(chainConfigs).length) return null;
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) };
@ -141,7 +140,7 @@ export async function fetchMessagesFromPiChain(
return [];
}
return logs.map(logToMessage).filter((m): m is Message => !!m);
return logs.map((l) => logToMessage(l, chainConfig)).filter((m): m is Message => !!m);
}
async function fetchLogsForAddress(
@ -358,7 +357,7 @@ async function tryFetchBlockFromProvider(
}
}
function logToMessage(log: LogWithTimestamp): Message | null {
function logToMessage(log: LogWithTimestamp, chainConfig: ChainConfig): Message | null {
let logDesc: ethers.utils.LogDescription;
try {
logDesc = mailbox.parseLog(log);
@ -378,28 +377,36 @@ function logToMessage(log: LogWithTimestamp): Message | null {
const originChainId = multiProvider.getChainId(message.origin);
const destinationChainId = multiProvider.getChainId(message.destination);
const tx: PartialTransactionReceipt = {
from: log.from ? normalizeAddress(log.from) : constants.AddressZero,
transactionHash: log.transactionHash,
blockNumber: BigNumber.from(log.blockNumber).toNumber(),
timestamp: log.timestamp,
gasUsed: 0, //TODO
};
return {
id: '', // No db id exists
msgId,
sender,
recipient,
status: MessageStatus.Unknown, // TODO
originDomainId: message.origin,
destinationDomainId: message.destination,
nonce: message.nonce,
originChainId,
destinationChainId,
originTimestamp: log.timestamp,
nonce: message.nonce,
originDomainId: message.origin,
destinationDomainId: message.destination,
body: message.body,
originTransaction: tx,
origin: {
timestamp: log.timestamp,
hash: log.transactionHash,
from: log.from ? normalizeAddress(log.from) : constants.AddressZero,
to: log.to ? normalizeAddress(log.to) : constants.AddressZero,
blockHash: log.blockHash,
blockNumber: BigNumber.from(log.blockNumber).toNumber(),
mailbox: chainConfig.contracts.mailbox,
nonce: 0,
// TODO get more gas info from tx
gasLimit: 0,
gasPrice: 0,
effectiveGasPrice: 0,
gasUsed: 0,
cumulativeGasUsed: 0,
maxFeePerGas: 0,
maxPriorityPerGas: 0,
},
isPiMsg: true,
};
} catch (error) {

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M11 5c0 1.7-1.3 3-3 3S5 6.7 5 5s1.3-3 3-3 3 1.3 3 3zM8 7c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm.3 7c-.1-.3-.2-.7-.2-1H3c0-.2.2-1 .8-1.7.7-.6 1.9-1.3 4.2-1.3h.7c.3-.3.5-.6.8-.9C9.1 9 8.6 9 8 9c-5 0-6 3-6 4s1 1 1 1h5.3z"/><path d="M12.5 9C10.6 9 9 10.6 9 12.5s1.6 3.5 3.5 3.5 3.5-1.6 3.5-3.5S14.4 9 12.5 9zm1.5 4c.1 0 .1.1.1.2l-.1.3c0 .1-.1 0-.2 0-.2-.1-.8-.5-1.1-.8.1.5.1 1 .1 1.3 0 .1 0 .2-.1.2h-.3c-.1 0-.1-.1-.1-.2 0-.3 0-.9.1-1.3-.3.3-.9.6-1.1.8-.1 0-.2.1-.2 0l-.2-.3c0-.1 0-.1.1-.2.3-.1.8-.4 1.2-.6-.4-.1-1-.5-1.2-.6-.1 0-.1-.1-.1-.2l.2-.3c0-.1.1 0 .2 0 .3.1.7.5 1.1.8-.1-.4-.1-1.1-.1-1.3 0-.1 0-.2.1-.2h.4c.1 0 .1.1.1.2 0 .3 0 .9-.1 1.3.4-.3.8-.6 1.1-.8.1 0 .2-.1.2 0l.2.3c0 .1 0 .1-.1.2l-1.2.6c.3.2.8.4 1 .6z"/></svg>

After

Width:  |  Height:  |  Size: 798 B

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414.05 3.555ZM0 4.697v7.104l5.803-3.558L0 4.697ZM6.761 8.83l-6.57 4.026A2 2 0 0 0 2 14h6.256A4.493 4.493 0 0 1 8 12.5a4.49 4.49 0 0 1 1.606-3.446l-.367-.225L8 9.586l-1.239-.757ZM16 4.697v4.974A4.491 4.491 0 0 0 12.5 8a4.49 4.49 0 0 0-1.965.45l-.338-.207L16 4.697Z" fill="#fff"/>
<path d="M16 12.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Zm-1.993-1.679a.5.5 0 0 0-.686.172l-1.17 1.95-.547-.547a.5.5 0 0 0-.708.708l.774.773a.75.75 0 0 0 1.174-.144l1.335-2.226a.5.5 0 0 0-.172-.686Z" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 646 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 2a2 2 0 0 0-2 2v8c0 1.1.9 2 2 2h5.5c.3 0 .5-.2.5-.5s-.2-.5-.5-.5H2c-.5 0-.8-.3-1-.7l5.6-3.5 1.4.8 7-4.2v3.1c0 .3.2.5.5.5s.5-.2.5-.5V4a2 2 0 0 0-2-2H2zm3.7 6.2L1 11.1V5.4l4.7 2.8zM1 4.2V4c0-.6.4-1 1-1h12c.6 0 1 .4 1 1v.2L8 8.4 1 4.2z"/><path d="M12.5 9C10.6 9 9 10.6 9 12.5s1.6 3.5 3.5 3.5 3.5-1.6 3.5-3.5S14.4 9 12.5 9zm-.5 5v-1.5c0-.3.2-.5.5-.5s.5.2.5.5V14c0 .3-.2.5-.5.5s-.5-.2-.5-.5zm0-3c0-.3.2-.5.5-.5s.5.2.5.5-.2.5-.5.5-.5-.2-.5-.5z"/></svg>

After

Width:  |  Height:  |  Size: 518 B

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M3 2.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-.5.5h-5a.5.5 0 0 1-.5-.5v-5Z" fill="#010101"/>
<path d="M1 2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v8a2 2 0 0 1 2 2v.5a.5.5 0 0 0 1 0V8h-.5a.5.5 0 0 1-.5-.5V4.375a.5.5 0 0 1 .5-.5h1.495c-.011-.476-.053-.894-.201-1.222a.97.97 0 0 0-.394-.458c-.184-.11-.464-.195-.9-.195a.5.5 0 0 1 0-1c.564 0 1.034.11 1.412.336.383.228.634.551.794.907.295.655.294 1.465.294 2.081v3.175a.5.5 0 0 1-.5.501H15v4.5a1.5 1.5 0 0 1-3 0V12a1 1 0 0 0-1-1v4h.5a.5.5 0 0 1 0 1H.5a.5.5 0 0 1 0-1H1V2Zm9 0a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v13h8V2Z" fill="#010101"/>
</svg>

After

Width:  |  Height:  |  Size: 680 B

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 18"><path d="M7.14 1.13c.76 0 1.49.23 2.02.65.54.43.84 1 .84 1.6v4.5H4.29v-4.5c0-.6.3-1.17.83-1.6a3.29 3.29 0 0 1 2.02-.66Zm4.29 6.75v-4.5c0-.9-.45-1.76-1.26-2.4C9.37.37 8.28 0 7.14 0 6.01 0 4.92.36 4.11.99c-.8.63-1.25 1.49-1.25 2.38v4.5c-.76 0-1.49.24-2.02.66-.54.43-.84 1-.84 1.6v5.62c0 .6.3 1.17.84 1.6.53.41 1.26.65 2.02.65h8.57c.76 0 1.48-.24 2.02-.66.53-.42.84-1 .84-1.59v-5.63c0-.6-.3-1.16-.84-1.59a3.29 3.29 0 0 0-2.02-.65Z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 520 B

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083l6-15Zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471-.47 1.178Z" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 373 B

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 0c-.69 0-1.843.265-2.928.56-1.11.3-2.229.655-2.887.87a1.54 1.54 0 0 0-1.044 1.262c-.596 4.477.787 7.795 2.465 9.99a11.777 11.777 0 0 0 2.517 2.453c.386.273.744.482 1.048.625.28.132.581.24.829.24s.548-.108.829-.24a7.159 7.159 0 0 0 1.048-.625 11.775 11.775 0 0 0 2.517-2.453c1.678-2.195 3.061-5.513 2.465-9.99a1.541 1.541 0 0 0-1.044-1.263 62.467 62.467 0 0 0-2.887-.87C9.843.266 8.69 0 8 0zm2.146 5.146a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647z" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 638 B

@ -1,39 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import NextCors from 'nextjs-cors';
import { fetchDeliveryStatus } from '../../features/deliveryStatus/fetchDeliveryStatus';
import type { MessageDeliveryStatusResponse } from '../../features/deliveryStatus/types';
import { Message } from '../../types';
import { logger } from '../../utils/logger';
// TODO: Deprecate this to simplify message details page
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<MessageDeliveryStatusResponse | string>,
) {
await NextCors(req, res, {
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
origin: '*',
optionsSuccessStatus: 200,
});
try {
const message = req.body as Message;
if (!message) throw new Error('No message in body');
const deliverStatus = await fetchDeliveryStatus(message);
res.status(200).json(deliverStatus);
} catch (error) {
const msg = 'Unable to determine message status';
logger.error(msg, error);
res.status(500).send(msg);
}
}
export const config = {
api: {
responseLimit: '5kb',
bodyParser: {
sizeLimit: '5kb',
},
},
};

@ -21,6 +21,7 @@ export default async function handler(
try {
const body = req.body as { chainId: number };
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);
res.status(200).json({ nonce });
} catch (error) {

@ -1,11 +1,4 @@
// Modeled after ethers.providers.TransactionReceipt
export interface PartialTransactionReceipt {
from: Address;
transactionHash: string;
blockNumber: number;
gasUsed: number;
timestamp: number;
}
import type { providers } from 'ethers';
// TODO consider reconciling with SDK's MessageStatus
export enum MessageStatus {
@ -15,25 +8,55 @@ export enum MessageStatus {
Failing = 'failing',
}
export interface MessageTxStub {
timestamp: number;
hash: string;
from: Address;
}
export interface MessageTx extends MessageTxStub {
to: Address;
blockHash: string;
blockNumber: number;
mailbox: Address;
nonce: number;
gasLimit: number;
gasPrice: number;
effectiveGasPrice;
gasUsed: number;
cumulativeGasUsed: number;
maxFeePerGas: number;
maxPriorityPerGas: number;
}
export interface MessageStub {
status: MessageStatus;
id: string; // Database id
msgId: string; // Message hash
status: MessageStatus;
nonce: number; // formerly leafIndex
sender: Address;
recipient: Address;
originDomainId: number;
destinationDomainId: number;
originChainId: number;
originDomainId: number;
destinationChainId: number;
originTimestamp: number; // Note, equivalent to timestamp in originTransaction
destinationTimestamp?: number; // Note, equivalent to timestamp in destinationTransaction
destinationDomainId: number;
origin: MessageTxStub;
destination?: MessageTxStub;
isPiMsg?: boolean;
}
export interface Message extends MessageStub {
nonce: number; // formerly leafIndex
body: string;
decodedBody?: string;
originTransaction: PartialTransactionReceipt;
destinationTransaction?: PartialTransactionReceipt;
origin: MessageTx;
destination?: MessageTx;
totalGasAmount?: string;
totalPayment?: string;
numPayments?: number;
}
export interface LogWithTimestamp extends providers.Log {
timestamp: number;
from?: Address;
to?: Address;
}

@ -0,0 +1,71 @@
import { formatUnits, parseUnits } from '@ethersproject/units';
import BigNumber from 'bignumber.js';
import { DISPLAY_DECIMALS, MIN_ROUNDED_VALUE } from '../consts/values';
import { logger } from './logger';
export type NumberT = BigNumber.Value;
export function fromWei(value: NumberT | null | undefined, toUnitName?: string): number {
if (!value) return 0;
const valueString = value.toString().trim();
const flooredValue = new BigNumber(valueString).toFixed(0, BigNumber.ROUND_FLOOR);
return parseFloat(formatUnits(flooredValue, toUnitName));
}
// Similar to fromWei above but rounds to set number of decimals
// with a minimum floor, configured per token
export function fromWeiRounded(
value: NumberT | null | undefined,
toUnitName?: string,
roundDownIfSmall = true,
decimals = DISPLAY_DECIMALS,
): string {
if (!value) return '0';
const flooredValue = new BigNumber(value).toFixed(0, BigNumber.ROUND_FLOOR);
const amount = new BigNumber(formatUnits(flooredValue, toUnitName));
if (amount.isZero()) return '0';
// If amount is less than min value
if (amount.lt(MIN_ROUNDED_VALUE)) {
if (roundDownIfSmall) return '0';
else return MIN_ROUNDED_VALUE.toString();
}
return amount.toFixed(decimals).toString();
}
export function toWei(value: NumberT | null | undefined): BigNumber {
if (!value) return new BigNumber(0);
const valueString = value.toString().trim();
const components = valueString.split('.');
if (components.length === 1) {
return new BigNumber(parseUnits(valueString).toString());
} else if (components.length === 2) {
const trimmedFraction = components[1].substring(0);
return new BigNumber(parseUnits(`${components[0]}.${trimmedFraction}`).toString());
} else {
throw new Error(`Cannot convert ${valueString} to wei`);
}
}
export function tryParseAmount(value: NumberT | null | undefined): BigNumber | null {
try {
if (!value) return null;
const parsed = new BigNumber(value);
if (!parsed || parsed.isNaN() || !parsed.isFinite()) return null;
else return parsed;
} catch (error) {
logger.warn('Error parsing amount', value);
return null;
}
}
// Checks if an amount is equal of nearly equal to balance within a small margin of error
// Necessary because amounts in the UI are often rounded
export function areAmountsNearlyEqual(amountInWei1: BigNumber, amountInWei2: NumberT) {
const minValueWei = toWei(MIN_ROUNDED_VALUE);
// Is difference btwn amount and balance less than min amount shown for token
return amountInWei1.minus(amountInWei2).abs().lt(minValueWei);
}

@ -3,14 +3,15 @@ import { BigNumber, providers } from 'ethers';
import { MultiProvider } from '@hyperlane-xyz/sdk';
import { config } from '../consts/config';
import type { LogWithTimestamp } from '../features/messages/queries/types';
import type { LogWithTimestamp } from '../types';
import { logger } from './logger';
import { hexToDecimal, tryHexToDecimal } from './number';
import { toDecimalNumber, tryToDecimalNumber } from './number';
import { fetchWithTimeout, sleep } from './timeout';
const BLOCK_EXPLORER_RATE_LIMIT = 5100; // once every 5.1 seconds
let lastExplorerQuery = 0;
// Used for crude rate-limiting of explorer queries without API keys
const hostToLastQueried: Record<string, number> = {};
export interface ExplorerQueryResponse<R> {
status: string;
@ -22,7 +23,7 @@ async function queryExplorer<P>(
multiProvider: MultiProvider,
chainId: number,
params: URLSearchParams,
useKey = true,
useKey = false,
) {
const baseUrl = multiProvider.tryGetExplorerApiUrl(chainId);
if (!baseUrl) throw new Error(`No valid URL found for explorer for chain ${chainId}`);
@ -40,7 +41,6 @@ async function queryExplorer<P>(
logger.debug('Querying explorer url:', url.toString());
const result = await executeQuery<P>(url);
lastExplorerQuery = Date.now();
return result;
}
@ -48,6 +48,7 @@ async function executeQuery<P>(url: URL) {
try {
if (!url.searchParams.has('apikey')) {
// Without an API key, rate limits are strict so enforce a wait if necessary
const lastExplorerQuery = hostToLastQueried[url.hostname] || 0;
const waitTime = BLOCK_EXPLORER_RATE_LIMIT - (Date.now() - lastExplorerQuery);
if (waitTime > 0) await sleep(waitTime);
}
@ -65,7 +66,7 @@ async function executeQuery<P>(url: URL) {
return json.result;
} finally {
lastExplorerQuery = Date.now();
hostToLastQueried[url.hostname] = Date.now();
}
}
@ -86,7 +87,7 @@ export async function queryExplorerForLogs(
multiProvider: MultiProvider,
chainId: number,
params: string,
useKey = true,
useKey = false,
): Promise<ExplorerLogEntry[]> {
const logs = await queryExplorer<ExplorerLogEntry[]>(
multiProvider,
@ -103,6 +104,7 @@ export async function queryExplorerForLogs(
return logs;
}
// TODO use Zod
function validateExplorerLog(log: ExplorerLogEntry) {
if (!log) throw new Error('Log is nullish');
if (!log.transactionHash) throw new Error('Log has no tx hash');
@ -116,10 +118,10 @@ export function toProviderLog(log: ExplorerLogEntry): LogWithTimestamp {
...log,
blockHash: '',
removed: false,
blockNumber: hexToDecimal(log.blockNumber),
timestamp: hexToDecimal(log.timeStamp) * 1000,
logIndex: tryHexToDecimal(log.logIndex) || 0,
transactionIndex: tryHexToDecimal(log.transactionIndex) || 0,
blockNumber: toDecimalNumber(log.blockNumber),
timestamp: toDecimalNumber(log.timeStamp) * 1000,
logIndex: tryToDecimalNumber(log.logIndex) || 0,
transactionIndex: tryToDecimalNumber(log.transactionIndex) || 0,
};
}
@ -127,7 +129,7 @@ export async function queryExplorerForTx(
multiProvider: MultiProvider,
chainId: number,
txHash: string,
useKey = true,
useKey = false,
) {
const params = new URLSearchParams({
module: 'proxy',
@ -152,7 +154,7 @@ export async function queryExplorerForTxReceipt(
multiProvider: MultiProvider,
chainId: number,
txHash: string,
useKey = true,
useKey = false,
) {
const params = new URLSearchParams({
module: 'proxy',
@ -177,7 +179,7 @@ export async function queryExplorerForBlock(
multiProvider: MultiProvider,
chainId: number,
blockNumber?: number | string,
useKey = true,
useKey = false,
) {
const params = new URLSearchParams({
module: 'proxy',

@ -1,18 +1,18 @@
import { BigNumber } from 'ethers';
import { BigNumber, BigNumberish } from 'ethers';
import { logger } from './logger';
export function tryHexToDecimal(value: string | number) {
export function tryToDecimalNumber(value: BigNumberish) {
try {
return BigNumber.from(value).toNumber();
return BigNumber.from(value.toString()).toNumber();
} catch (error) {
logger.debug(`Error parsing hex number ${value}`);
return null;
}
}
export function hexToDecimal(value: string | number) {
const result = tryHexToDecimal(value);
if (!result) throw new Error(`Error parsing hex number ${value}`);
export function toDecimalNumber(value: BigNumberish) {
const result = tryToDecimalNumber(value);
if (result === null || result === undefined) throw new Error(`Error parsing hex number ${value}`);
return result;
}

@ -1294,14 +1294,14 @@ __metadata:
languageName: node
linkType: hard
"@hyperlane-xyz/core@npm:1.2.1":
version: 1.2.1
resolution: "@hyperlane-xyz/core@npm:1.2.1"
"@hyperlane-xyz/core@npm:1.2.2":
version: 1.2.2
resolution: "@hyperlane-xyz/core@npm:1.2.2"
dependencies:
"@hyperlane-xyz/utils": 1.2.1
"@hyperlane-xyz/utils": 1.2.2
"@openzeppelin/contracts": ^4.8.0
"@openzeppelin/contracts-upgradeable": ^4.8.0
checksum: e1b7e6c36364558663602107343d267453e8071400d5bab1ca71681a1cc13cef88e94ab830699740790d0abd3cad8d565ccd3f04fdc0ba30138809fd8a94ad42
checksum: f20bdb6aed949a5b4a16fbb5bbd232647c39f515cc68985d9445f76082c190856e99d80f9ff0565febdd55047e4b1e0b111345a08c68971574fb3a87e5c0cff5
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.1
"@hyperlane-xyz/widgets": 1.2.2-beta0
"@hyperlane-xyz/sdk": 1.2.2
"@hyperlane-xyz/widgets": 1.2.2
"@metamask/jazzicon": "https://github.com/jmrossy/jazzicon#7a8df28974b4e81129bfbe3cab76308b889032a6"
"@rainbow-me/rainbowkit": ^0.11.0
"@tanstack/react-query": ^4.24.10
@ -1323,6 +1323,7 @@ __metadata:
"@typescript-eslint/eslint-plugin": ^5.53.0
"@typescript-eslint/parser": ^5.53.0
autoprefixer: ^10.4.13
bignumber.js: ^9.1.1
buffer: ^6.0.3
eslint: ^8.34.0
eslint-config-next: ^13.2.0
@ -1348,39 +1349,39 @@ __metadata:
languageName: unknown
linkType: soft
"@hyperlane-xyz/sdk@npm:1.2.1":
version: 1.2.1
resolution: "@hyperlane-xyz/sdk@npm:1.2.1"
"@hyperlane-xyz/sdk@npm:1.2.2":
version: 1.2.2
resolution: "@hyperlane-xyz/sdk@npm:1.2.2"
dependencies:
"@hyperlane-xyz/core": 1.2.1
"@hyperlane-xyz/utils": 1.2.1
"@hyperlane-xyz/core": 1.2.2
"@hyperlane-xyz/utils": 1.2.2
"@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: 6fd7a276f6aca00ee4e0df560c5bee70adeff4bcc1193b0e34c4edb06df89fdc979171fe6cc7fba37b4a8c24049e0d37e3208d6bc026cae649a68cb10853909c
checksum: 5e6e856e413474ab9a241ce8c507683db7ff6933649d58d17323deff7c13393ce2c7b973bbd404bdd99409ff883d934b386a1e7c1830fa7257c08a893c742ceb
languageName: node
linkType: hard
"@hyperlane-xyz/utils@npm:1.2.1":
version: 1.2.1
resolution: "@hyperlane-xyz/utils@npm:1.2.1"
"@hyperlane-xyz/utils@npm:1.2.2":
version: 1.2.2
resolution: "@hyperlane-xyz/utils@npm:1.2.2"
dependencies:
ethers: ^5.7.2
checksum: ac8e88bd9492e2c5dd5daaa5e81afdef1aaca68f114abc3e5aa100e286f5e071734108da7ef23c161bd90e0561180a3ca202b630d68269fa5425e84b05cdd5ef
checksum: ac118a1604eb3d14064ccfc6208735ea6fbf909cf178592a22570864fcbce4ba55833e0bb7ec0942240eec7db36412ec21ea4ce10f3c3dd0167213c6734b302b
languageName: node
linkType: hard
"@hyperlane-xyz/widgets@npm:1.2.2-beta0":
version: 1.2.2-beta0
resolution: "@hyperlane-xyz/widgets@npm:1.2.2-beta0"
"@hyperlane-xyz/widgets@npm:1.2.2":
version: 1.2.2
resolution: "@hyperlane-xyz/widgets@npm:1.2.2"
peerDependencies:
"@hyperlane-xyz/sdk": ^1.2
react: ^18
react-dom: ^18
checksum: ba4bc62e28f99576c0099b3e26dbd0b5ea974345da9aab55b1c7fc4c5eb321b35456a7952d730ebb74f40c824bcf88ce06cdaf4e08d9d8f4b0fbb0f4cd0628bf
checksum: bbe6a3180555f214aac5897e2837fa1d15f99d4fe931cd194ba83282be9606b348aa46d36a274f9226f36cf7b69bd0e30b73168e0593eaec6d642b9042a2dc11
languageName: node
linkType: hard
@ -4178,6 +4179,13 @@ __metadata:
languageName: node
linkType: hard
"bignumber.js@npm:^9.1.1":
version: 9.1.1
resolution: "bignumber.js@npm:9.1.1"
checksum: ad243b7e2f9120b112d670bb3d674128f0bd2ca1745b0a6c9df0433bd2c0252c43e6315d944c2ac07b4c639e7496b425e46842773cf89c6a2dcd4f31e5c4b11e
languageName: node
linkType: hard
"binary-extensions@npm:^2.0.0":
version: 2.2.0
resolution: "binary-extensions@npm:2.2.0"

Loading…
Cancel
Save