Add tests for pi message fetching
Fix pi fetching bugs
Add Unknown Message.status
pull/29/head
J M Rossy 2 years ago
parent 37c97b6ba5
commit f2cddc9cbc
  1. 1
      .eslintignore
  2. 16
      jest.config.js
  3. 3
      package.json
  4. 3
      src/features/messages/cards/ContentDetailsCard.tsx
  5. 93
      src/features/messages/queries/usePiChainMessageQuery.test.ts
  6. 143
      src/features/messages/queries/usePiChainMessageQuery.ts
  7. 1
      src/types.ts
  8. 16
      src/utils/explorers.ts
  9. 2030
      yarn.lock

@ -4,3 +4,4 @@ build
coverage
next.config.js
tailwind.config.js
jest.config.js

@ -0,0 +1,16 @@
const nextJest = require('next/jest')
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
// Add any custom config to be passed to Jest
/** @type {import('jest').Config} */
const customJestConfig = {
// Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)

@ -26,6 +26,7 @@
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.0.0",
"@types/jest": "^29.4.0",
"@types/node": "^18.11.18",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
@ -35,6 +36,7 @@
"eslint": "^8.34.0",
"eslint-config-next": "^13.2.0",
"eslint-config-prettier": "^8.6.0",
"jest": "^29.4.3",
"postcss": "^8.4.21",
"prettier": "^2.8.4",
"tailwindcss": "^3.2.7",
@ -57,6 +59,7 @@
"typecheck": "tsc",
"lint": "next lint",
"start": "next start",
"test": "jest",
"prettier": "prettier --write ./src"
},
"types": "dist/src/index.d.ts",

@ -8,6 +8,7 @@ import { SelectField } from '../../../components/input/SelectField';
import { Card } from '../../../components/layout/Card';
import { MAILBOX_VERSION } from '../../../consts/environments';
import { Message } from '../../../types';
import { ensureLeading0x } from '../../../utils/addresses';
import { tryUtf8DecodeBytes } from '../../../utils/string';
import { CodeBlock, LabelAndCodeBlock } from './CodeBlock';
@ -73,7 +74,7 @@ export function ContentDetailsCard({
<KeyValueRow
label="Message Id:"
labelWidth="w-20"
display={msgId}
display={ensureLeading0x(msgId)}
displayWidth="w-60 sm:w-80"
showCopy={true}
blurValue={shouldBlur}

@ -0,0 +1,93 @@
import { chainMetadata, hyperlaneCoreAddresses } from '@hyperlane-xyz/sdk';
import { ChainConfig } from '../../chains/chainConfig';
import { fetchMessagesFromPiChain } from './usePiChainMessageQuery';
jest.setTimeout(15000);
const goerliMailbox = hyperlaneCoreAddresses.goerli.mailbox;
const goerliConfigWithExplorer: ChainConfig = {
...chainMetadata.goerli,
contracts: { mailbox: goerliMailbox },
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { blockExplorers, ...goerliConfigNoExplorer } = goerliConfigWithExplorer;
// https://explorer.hyperlane.xyz/message/0d9dc662da32d3737835295e9c9eb2b92ac630aec2756b93a187b8fd22a82afd
const txHash = '0xb81ba87ee7ae30dea0f67f7f25b67d973cec6533e7407ea7a8c761f39d8dee1b';
const msgId = '0x0d9dc662da32d3737835295e9c9eb2b92ac630aec2756b93a187b8fd22a82afd';
const senderAddress = '0x0637a1360ea44602dae5c4ba515c2bcb6c762fbc';
const recipientAddress = '0x921d3a71386d3ab8f3ad4ec91ce1556d5fc26859';
const goerliMessage = {
body: '0x48656c6c6f21',
destinationChainId: 44787,
destinationDomainId: 44787,
destinationTimestamp: 0,
destinationTransaction: {
blockNumber: 0,
from: '0x0000000000000000000000000000000000000000',
gasUsed: 0,
timestamp: 0,
transactionHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
},
id: '',
msgId: '0x0d9dc662da32d3737835295e9c9eb2b92ac630aec2756b93a187b8fd22a82afd',
nonce: 20763,
originChainId: 5,
originDomainId: 5,
originTimestamp: 0,
originTransaction: {
blockNumber: 8600958,
from: '0x0000000000000000000000000000000000000000',
gasUsed: 0,
timestamp: 0,
transactionHash: '0xb81ba87ee7ae30dea0f67f7f25b67d973cec6533e7407ea7a8c761f39d8dee1b',
},
recipient: '0x000000000000000000000000921d3a71386d3ab8f3ad4ec91ce1556d5fc26859',
sender: '0x0000000000000000000000000637a1360ea44602dae5c4ba515c2bcb6c762fbc',
status: 'unknown',
};
describe('fetchMessagesFromPiChain', () => {
it('Fetches messages using explorer for tx hash', async () => {
const messages = await fetchMessagesFromPiChain(goerliConfigWithExplorer, txHash);
expect(messages).toEqual([goerliMessage]);
});
it.skip('Fetches messages using explorer for msg id', async () => {
const messages = await fetchMessagesFromPiChain(goerliConfigWithExplorer, msgId);
expect(messages).toEqual([goerliMessage]);
});
it.skip('Fetches messages using explorer for sender address', async () => {
const messages = await fetchMessagesFromPiChain(goerliConfigWithExplorer, senderAddress);
expect(messages).toEqual([goerliMessage]);
});
it.skip('Fetches messages using explorer for recipient address', async () => {
const messages = await fetchMessagesFromPiChain(goerliConfigWithExplorer, recipientAddress);
expect(messages).toEqual([goerliMessage]);
});
it('Fetches messages using provider for tx hash', async () => {
const messages = await fetchMessagesFromPiChain(goerliConfigNoExplorer, txHash);
expect(messages).toEqual([goerliMessage]);
});
it('Fetches messages using provider for msg id', async () => {
const messages = await fetchMessagesFromPiChain(goerliConfigNoExplorer, msgId);
expect(messages).toEqual([goerliMessage]);
});
it('Fetches messages using provider for sender address', async () => {
const messages = await fetchMessagesFromPiChain(goerliConfigNoExplorer, senderAddress);
const testMsg = messages.find((m) => m.msgId === msgId);
expect([testMsg]).toEqual([goerliMessage]);
});
it('Fetches messages using provider for recipient address', async () => {
const messages = await fetchMessagesFromPiChain(goerliConfigNoExplorer, recipientAddress);
const testMsg = messages.find((m) => m.msgId === msgId);
expect([testMsg]).toEqual([goerliMessage]);
});
it('Throws error for invalid input', async () => {
await expect(
fetchMessagesFromPiChain(goerliConfigWithExplorer, 'invalidInput'),
).rejects.toThrow();
});
});

@ -1,12 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { providers } from 'ethers';
import { BigNumber, constants, ethers, providers } from 'ethers';
import { Mailbox__factory } from '@hyperlane-xyz/core';
import { utils } from '@hyperlane-xyz/utils';
import { getMultiProvider, getProvider } from '../../../multiProvider';
import { useStore } from '../../../store';
import { Message, MessageStatus } from '../../../types';
import { Message, MessageStatus, PartialTransactionReceipt } from '../../../types';
import {
ensureLeading0x,
isValidAddressFast,
@ -22,11 +22,13 @@ import { ChainConfig } from '../../chains/chainConfig';
import { isValidSearchQuery } from './useMessageQuery';
const PROVIDER_LOGS_BLOCK_WINDOW = 150_000;
const mailbox = Mailbox__factory.createInterface();
const dispatchTopic0 = mailbox.getEventTopic('Dispatch');
const dispatchIdTopic0 = mailbox.getEventTopic('DispatchId');
const processTopic0 = mailbox.getEventTopic('Process');
const processIdTopic0 = mailbox.getEventTopic('ProcessId');
// const processTopic0 = mailbox.getEventTopic('Process');
// const processIdTopic0 = mailbox.getEventTopic('ProcessId');
// Query 'Permissionless Interoperability (PI)' chains using custom
// chain configs in store state
@ -85,32 +87,33 @@ searchForMessages(input):
GOTO hash search above
*/
async function fetchMessagesFromPiChain(
export async function fetchMessagesFromPiChain(
chainConfig: ChainConfig,
input: string,
): Promise<Message[]> {
const { chainId, blockExplorers } = chainConfig;
const useExplorer = !!blockExplorers?.[0]?.apiUrl;
const formattedInput = ensureLeading0x(input);
let logs: providers.Log[] | null = null;
if (isValidAddressFast(input)) {
logs = await fetchLogsForAddress(chainConfig, input, useExplorer);
let logs: providers.Log[];
if (isValidAddressFast(formattedInput)) {
logs = await fetchLogsForAddress(chainConfig, formattedInput, useExplorer);
} else if (isValidTransactionHash(input)) {
logs = await fetchLogsForTxHash(chainConfig, input, useExplorer);
if (!logs) {
logs = await fetchLogsForTxHash(chainConfig, formattedInput, useExplorer);
if (!logs.length) {
// Input may be a msg id
logs = await fetchLogsForMsgId(chainConfig, input, useExplorer);
logs = await fetchLogsForMsgId(chainConfig, formattedInput, useExplorer);
}
} else {
throw new Error('Invalid PI search input');
}
if (!logs?.length) {
if (!logs.length) {
// Throw so Promise.any caller doesn't trigger
throw new Error(`No messages found for chain ${chainId}`);
}
return logs.map(logToMessage);
return logs.map(logToMessage).filter((m): m is Message => !!m);
}
async function fetchLogsForAddress(
@ -118,17 +121,16 @@ async function fetchLogsForAddress(
address: Address,
useExplorer?: boolean,
) {
logger.debug(`Fetching logs for address ${address} on chain ${chainId}`);
const mailboxAddr = contracts.mailbox;
const dispatchTopic1 = ensureLeading0x(address);
const dispatchTopic3 = utils.addressToBytes32(dispatchTopic1);
const processTopic1 = dispatchTopic3;
const processTopic3 = dispatchTopic1;
const dispatchTopic1 = utils.addressToBytes32(address);
const dispatchTopic3 = dispatchTopic1;
if (useExplorer) {
return fetchLogsFromExplorer(
[
`&topic0=${dispatchTopic0}&topic0_1_opr=and&topic1=${dispatchTopic1}&topic1_3_opr=or&topic3=${dispatchTopic3}`,
`&topic0=${processTopic0}&topic0_1_opr=and&topic1=${processTopic1}&topic1_3_opr=or&topic3=${processTopic3}`,
// `&topic0=${processTopic0}&topic0_1_opr=and&topic1=${dispatchTopic3}&topic1_3_opr=or&topic3=${dispatchTopic1}`,
],
mailboxAddr,
chainId,
@ -138,8 +140,8 @@ async function fetchLogsForAddress(
[
[dispatchTopic0, dispatchTopic1],
[dispatchTopic0, null, null, dispatchTopic3],
[processTopic0, processTopic1],
[processTopic0, null, null, processTopic3],
// [processTopic0, dispatchTopic3],
// [processTopic0, null, null, dispatchTopic1],
],
mailboxAddr,
chainId,
@ -148,40 +150,39 @@ async function fetchLogsForAddress(
}
async function fetchLogsForTxHash({ chainId }: ChainConfig, txHash: string, useExplorer: boolean) {
logger.debug(`Fetching logs for txHash ${txHash} on chain ${chainId}`);
if (useExplorer) {
try {
const txReceipt = await queryExplorerForTxReceipt(chainId, txHash);
const txReceipt = await queryExplorerForTxReceipt(chainId, txHash, false);
logger.debug(`Tx receipt found from explorer for chain ${chainId}`);
console.log(txReceipt);
return txReceipt.logs;
} catch (error) {
logger.debug(`Tx hash not found in explorer for chain ${chainId}`);
return null;
}
} else {
const provider = getProvider(chainId);
const txReceipt = await provider.getTransactionReceipt(txHash);
console.log(txReceipt);
if (txReceipt) {
logger.debug(`Tx receipt found from provider for chain ${chainId}`);
return txReceipt.logs;
} else {
logger.debug(`Tx hash not found from provider for chain ${chainId}`);
return null;
}
}
return [];
}
async function fetchLogsForMsgId(chainConfig: ChainConfig, msgId: string, useExplorer: boolean) {
const { contracts, chainId } = chainConfig;
logger.debug(`Fetching logs for msgId ${msgId} on chain ${chainId}`);
const mailboxAddr = contracts.mailbox;
const topic1 = ensureLeading0x(msgId);
const topic1 = msgId;
let logs: providers.Log[];
if (useExplorer) {
logs = await fetchLogsFromExplorer(
[
`&topic0=${dispatchIdTopic0}&topic0_1_opr=and&topic1=${topic1}`,
`&topic0=${processIdTopic0}&topic0_1_opr=and&topic1=${topic1}`,
// `&topic0=${processIdTopic0}&topic0_1_opr=and&topic1=${topic1}`,
],
mailboxAddr,
chainId,
@ -189,14 +190,16 @@ async function fetchLogsForMsgId(chainConfig: ChainConfig, msgId: string, useExp
} else {
logs = await fetchLogsFromProvider(
[
[dispatchTopic0, topic1],
[processTopic0, topic1],
[dispatchIdTopic0, topic1],
// [processIdTopic0, topic1],
],
mailboxAddr,
chainId,
);
}
// Grab first tx hash found in any log and get all logs for that tx
// Necessary because DispatchId/ProcessId logs don't contain useful info
if (logs.length) {
const txHash = logs[0].transactionHash;
logger.debug('Found tx hash with log of msg id', txHash);
@ -207,13 +210,13 @@ async function fetchLogsForMsgId(chainConfig: ChainConfig, msgId: string, useExp
}
async function fetchLogsFromExplorer(paths: Array<string>, contractAddr: Address, chainId: number) {
const pathBase = `api?module=logs&action=getLogs&fromBlock=0&toBlock=999999999&address=${contractAddr}`;
const logs = (
await Promise.all(paths.map((p) => queryExplorerForLogs(chainId, `${pathBase}${p}`)))
)
.flat()
.map(toProviderLog);
console.log(logs);
const base = `?module=logs&action=getLogs&fromBlock=0&toBlock=999999999&address=${contractAddr}`;
let logs: providers.Log[] = [];
for (const path of paths) {
// Originally use parallel requests here with Promise.all but immediately hit rate limit errors
const result = await queryExplorerForLogs(chainId, `${base}${path}`, undefined, false);
logs = [...logs, ...result.map(toProviderLog)];
}
return logs;
}
@ -223,46 +226,68 @@ async function fetchLogsFromProvider(
chainId: number,
) {
const provider = getProvider(chainId);
const latestBlock = await provider.getBlockNumber();
// TODO may need chunking here to avoid RPC errors
const logs = (
await Promise.all(
topics.map((t) =>
provider.getLogs({
fromBlock: latestBlock - PROVIDER_LOGS_BLOCK_WINDOW,
toBlock: 'latest',
address: contractAddr,
topics: t,
}),
),
)
).flat();
console.log(logs);
return logs;
}
function logToMessage(log: providers.Log): Message {
function logToMessage(log: providers.Log): Message | null {
let logDesc: ethers.utils.LogDescription;
try {
logDesc = mailbox.parseLog(log);
if (logDesc.name.toLowerCase() !== 'dispatch') return null;
} catch (error) {
// Probably not a message log, ignore
return null;
}
const bytes = logDesc.args['message'];
const message = utils.parseMessage(bytes);
const tx: PartialTransactionReceipt = {
from: constants.AddressZero, //TODO
transactionHash: log.transactionHash,
blockNumber: BigNumber.from(log.blockNumber).toNumber(),
gasUsed: 0, //TODO
timestamp: 0, // TODO
};
const emptyTx = {
from: constants.AddressZero, //TODO
transactionHash: constants.HashZero,
blockNumber: 0,
gasUsed: 0,
timestamp: 0,
};
const multiProvider = getMultiProvider();
const bytes = mailbox.parseLog(log).args['message'];
const parsed = utils.parseMessage(bytes);
return {
id: '', // No db id exists
msgId: utils.messageId(bytes),
status: MessageStatus.Pending, // TODO
sender: parsed.sender,
recipient: parsed.recipient,
originDomainId: parsed.origin,
destinationDomainId: parsed.destination,
originChainId: multiProvider.getChainId(parsed.origin),
destinationChainId: multiProvider.getChainId(parsed.destination),
originTimestamp: 0, // TODO
destinationTimestamp: undefined, // TODO
nonce: parsed.nonce,
body: parsed.body,
originTransaction: {
from: '0x', //TODO
transactionHash: log.transactionHash,
blockNumber: log.blockNumber,
gasUsed: 0,
timestamp: 0, //TODO
},
destinationTransaction: undefined, // TODO
status: MessageStatus.Unknown, // TODO
sender: message.sender,
recipient: message.recipient,
originDomainId: message.origin,
destinationDomainId: message.destination,
originChainId: multiProvider.getChainId(message.origin),
destinationChainId: multiProvider.getChainId(message.destination),
originTimestamp: tx.timestamp, // TODO
destinationTimestamp: 0, // TODO
nonce: message.nonce,
body: message.body,
originTransaction: tx,
destinationTransaction: emptyTx,
};
}

@ -9,6 +9,7 @@ export interface PartialTransactionReceipt {
// TODO consider reconciling with SDK's MessageStatus
export enum MessageStatus {
Unknown = 'unknown',
Pending = 'pending',
Delivered = 'delivered',
Failing = 'failing',

@ -4,7 +4,6 @@ import { config } from '../consts/config';
import { getMultiProvider } from '../multiProvider';
import { logger } from './logger';
import { retryAsync } from './retry';
import { fetchWithTimeout } from './timeout';
import { isValidHttpUrl } from './url';
@ -27,9 +26,10 @@ export function getExplorerApiUrl(chainId: number) {
}
export function getTxExplorerUrl(chainId: number, hash?: string) {
const baseUrl = getExplorerUrl(chainId);
if (!hash || !baseUrl) return null;
return `${baseUrl}/tx/${hash}`;
if (!hash) return null;
const url = getMultiProvider().getExplorerTxUrl(chainId, { hash });
if (isValidHttpUrl(url)) return url;
else return null;
}
export async function queryExplorer<P>(chainId: number, path: string, useKey = true) {
@ -45,7 +45,7 @@ export async function queryExplorer<P>(chainId: number, path: string, useKey = t
url += `&apikey=${apiKey}`;
}
const result = await retryAsync(() => executeQuery<P>(url), 2, 1000);
const result = await executeQuery<P>(url);
return result;
}
@ -114,7 +114,7 @@ export function toProviderLog(log: ExplorerLogEntry): providers.Log {
}
export async function queryExplorerForTx(chainId: number, txHash: string, useKey = true) {
const path = `api?module=proxy&action=eth_getTransactionByHash&txhash=${txHash}`;
const path = `?module=proxy&action=eth_getTransactionByHash&txhash=${txHash}`;
const tx = await queryExplorer<providers.TransactionResponse>(chainId, path, useKey);
if (!tx || tx.hash.toLowerCase() !== txHash.toLowerCase()) {
const msg = 'Invalid tx result';
@ -125,7 +125,7 @@ export async function queryExplorerForTx(chainId: number, txHash: string, useKey
}
export async function queryExplorerForTxReceipt(chainId: number, txHash: string, useKey = true) {
const path = `api?module=proxy&action=eth_getTransactionReceipt&txhash=${txHash}`;
const path = `?module=proxy&action=eth_getTransactionReceipt&txhash=${txHash}`;
const tx = await queryExplorer<providers.TransactionReceipt>(chainId, path, useKey);
if (!tx || tx.transactionHash.toLowerCase() !== txHash.toLowerCase()) {
const msg = 'Invalid tx result';
@ -140,7 +140,7 @@ export async function queryExplorerForBlock(
blockNumber?: number | string,
useKey = true,
) {
const path = `api?module=proxy&action=eth_getBlockByNumber&tag=${
const path = `?module=proxy&action=eth_getBlockByNumber&tag=${
blockNumber || 'latest'
}&boolean=false`;
const block = await queryExplorer<providers.Block>(chainId, path, useKey);

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