Skip to content

Commit

Permalink
Add metadata health tests and improve export types (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmrossy authored Apr 11, 2024
1 parent 9b4df67 commit e96c12a
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 7 deletions.
5 changes: 5 additions & 0 deletions .changeset/cold-rice-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperlane-xyz/registry': patch
---

Improve types for chain metadata exports
18 changes: 17 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,20 @@ jobs:
.yarn/cache
key: ${{ runner.os }}-yarn-cache-${{ hashFiles('./yarn.lock') }}
- name: test
run: yarn run test
run: yarn run test:unit


health:
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v3
- uses: actions/cache@v3
with:
path: |
**/node_modules
.yarn/cache
key: ${{ runner.os }}-yarn-cache-${{ hashFiles('./yarn.lock') }}
- name: test
run: yarn run test:health

59 changes: 59 additions & 0 deletions .github/workflows/cron.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: cron

# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
on:
schedule:
- cron: '45 14 * * *'
workflow_dispatch:

env:
LOG_LEVEL: DEBUG
LOG_FORMAT: PRETTY

jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/cache@v3
with:
path: |
**/node_modules
.yarn/cache
key: ${{ runner.os }}-yarn-cache-${{ hashFiles('./yarn.lock') }}
- name: yarn-install
run: yarn install

build:
runs-on: ubuntu-latest
needs: [install]
steps:
- uses: actions/checkout@v3
- uses: actions/cache@v3
with:
path: |
**/node_modules
.yarn/cache
key: ${{ runner.os }}-yarn-cache-${{ hashFiles('./yarn.lock') }}
- name: build
run: yarn run build

metadata-health:
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v3
- uses: actions/cache@v3
with:
path: |
**/node_modules
.yarn/cache
key: ${{ runner.os }}-yarn-cache-${{ hashFiles('./yarn.lock') }}

- name: Metadata Health Check
run: yarn test:health

- name: Post to discord webhook if metadata check fails
if: failure()
run: |
curl -X POST -H 'Content-type: application/json' --data '{"content":"SDK metadata check failed, see ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' ${{ secrets.DISCORD_WEBHOOK_URL }}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
"clean": "rm -rf ./dist ./tmp",
"build": "tsx ./scripts/build.ts && tsc",
"prettier": "prettier --write ./chains ./deployments",
"test": "yarn build && mocha --config .mocharc.json './test/**/*.test.ts' --exit",
"test:unit": "yarn build && mocha --config .mocharc.json './test/unit/*.test.ts' --exit",
"test:health": "yarn build && mocha --config .mocharc.json './test/health/*.test.ts' --exit",
"prepare": "husky",
"release": "yarn build && yarn changeset publish",
"version:prepare": "yarn changeset version && yarn install --no-immutable",
Expand Down
24 changes: 20 additions & 4 deletions scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import { CoreChains } from '../chains/core';

function genJsExport(data, exportName) {
return `export const ${exportName} = ${JSON.stringify(data, null, 2)};`;
return `export const ${exportName} = ${JSON.stringify(data, null, 2)}`;
}

function genChainMetadataExport(data, exportName) {
return `import type { ChainMetadata } from '@hyperlane-xyz/sdk';
${genJsExport(data, exportName)} as ChainMetadata`;
}

function genChainMetadataMapExport(data, exportName) {
return `import type { ChainMetadata, ChainMap } from '@hyperlane-xyz/sdk';
${genJsExport(data, exportName)} as ChainMap<ChainMetadata>`;
}

let chainMetadata = {};
Expand All @@ -28,7 +38,7 @@ for (const file of fs.readdirSync('./chains')) {
fs.mkdirSync(`${tsOutPath}`, { recursive: true });
fs.copyFileSync(`${inDirPath}/metadata.yaml`, `${assetOutPath}/metadata.yaml`);
fs.writeFileSync(`${assetOutPath}/metadata.json`, JSON.stringify(metadata, null, 2));
fs.writeFileSync(`${tsOutPath}/metadata.ts`, genJsExport(metadata, 'metadata'));
fs.writeFileSync(`${tsOutPath}/metadata.ts`, genChainMetadataExport(metadata, 'metadata'));

// Convert and copy addresses if there are any
if (fs.existsSync(`${inDirPath}/addresses.yaml`)) {
Expand All @@ -48,12 +58,18 @@ fs.mkdirSync(`./tmp`, { recursive: true });
// Start with the contents of core.ts for the new index file
fs.copyFileSync(`./chains/core.ts`, `./tmp/index.ts`);
// Create files for the chain metadata and addresses maps
fs.writeFileSync(`./tmp/chainMetadata.ts`, genJsExport(chainMetadata, 'chainMetadata'));
fs.writeFileSync(
`./tmp/chainMetadata.ts`,
genChainMetadataMapExport(chainMetadata, 'chainMetadata'),
);
fs.writeFileSync(`./tmp/chainAddresses.ts`, genJsExport(chainAddresses, 'chainAddresses'));
// And also alternate versions with just the core chains
const coreChainMetadata = pick<any>(chainMetadata, CoreChains);
const coreChainAddresses = pick<any>(chainAddresses, CoreChains);
fs.writeFileSync(`./tmp/coreChainMetadata.ts`, genJsExport(coreChainMetadata, 'coreChainMetadata'));
fs.writeFileSync(
`./tmp/coreChainMetadata.ts`,
genChainMetadataMapExport(coreChainMetadata, 'coreChainMetadata'),
);
fs.writeFileSync(
`./tmp/coreChainAddresses.ts`,
genJsExport(coreChainAddresses, 'coreChainAddresses'),
Expand Down
81 changes: 81 additions & 0 deletions test/health/explorer-health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { ChainMetadata } from '@hyperlane-xyz/sdk';
import {
getExplorerAddressUrl,
getExplorerBaseUrl,
getExplorerTxUrl,
} from '@hyperlane-xyz/sdk/dist/metadata/blockExplorer.js';
import { Address, ProtocolType, sleep } from '@hyperlane-xyz/utils';
import { expect } from 'chai';
import { chainMetadata } from '../../dist/index.js';

const HEALTH_CHECK_TIMEOUT = 10_000; // 10s
const HEALTH_CHECK_DELAY = 3_000; // 3s

const PROTOCOL_TO_ADDRESS: Record<ProtocolType, Address> = {
[ProtocolType.Ethereum]: '0x0000000000000000000000000000000000000000',
[ProtocolType.Sealevel]: '11111111111111111111111111111111',
[ProtocolType.Cosmos]: 'cosmos100000000000000000000000000000000000000',
[ProtocolType.Fuel]: '',
};

const PROTOCOL_TO_TX_HASH: Record<ProtocolType, Address> = {
[ProtocolType.Ethereum]: '0x0000000000000000000000000000000000000000000000000000000000000000',
[ProtocolType.Sealevel]:
'1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111',
[ProtocolType.Cosmos]: '0000000000000000000000000000000000000000000000000000000000000000',
[ProtocolType.Fuel]: '',
};

export async function isBlockExplorerHealthy(
chainMetadata: ChainMetadata,
address?: Address,
txHash?: string,
): Promise<boolean> {
const baseUrl = getExplorerBaseUrl(chainMetadata);
if (!baseUrl) return false;
console.debug(`Got base url: ${baseUrl}`);

console.debug(`Checking explorer home for ${chainMetadata.name}`);
const homeReq = await fetch(baseUrl);
if (!homeReq.ok) return false;
console.debug(`Explorer home okay for ${chainMetadata.name}`);

if (address) {
console.debug(`Checking explorer address page for ${chainMetadata.name}`);
const addressUrl = getExplorerAddressUrl(chainMetadata, address);
if (!addressUrl) return false;
console.debug(`Got address url: ${addressUrl}`);
const addressReq = await fetch(addressUrl);
if (!addressReq.ok && addressReq.status !== 404) return false;
console.debug(`Explorer address page okay for ${chainMetadata.name}`);
}

if (txHash) {
console.debug(`Checking explorer tx page for ${chainMetadata.name}`);
const txUrl = getExplorerTxUrl(chainMetadata, txHash);
if (!txUrl) return false;
console.debug(`Got tx url: ${txUrl}`);
const txReq = await fetch(txUrl);
if (!txReq.ok && txReq.status !== 404) return false;
console.debug(`Explorer tx page okay for ${chainMetadata.name}`);
}

return true;
}

describe('Chain block explorer health', async () => {
for (const [chain, metadata] of Object.entries(chainMetadata)) {
if (!metadata.blockExplorers?.length) continue;
it(`${chain} default explorer is healthy`, async () => {
const isHealthy = await isBlockExplorerHealthy(
metadata,
PROTOCOL_TO_ADDRESS[metadata.protocol],
PROTOCOL_TO_TX_HASH[metadata.protocol],
);
if (!isHealthy) await sleep(HEALTH_CHECK_DELAY);
expect(isHealthy).to.be.true;
})
.timeout(HEALTH_CHECK_TIMEOUT + HEALTH_CHECK_DELAY * 2)
.retries(3);
}
});
91 changes: 91 additions & 0 deletions test/health/rpc-health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
ChainMetadata,
CosmJsProvider,
CosmJsWasmProvider,
EthersV5Provider,
ProviderType,
RpcUrl,
SolanaWeb3Provider,
protocolToDefaultProviderBuilder,
} from '@hyperlane-xyz/sdk';
import { sleep } from '@hyperlane-xyz/utils';
import { expect } from 'chai';
import { chainAddresses, chainMetadata } from '../../dist/index.js';

import { Mailbox__factory } from '@hyperlane-xyz/core';

const HEALTH_CHECK_TIMEOUT = 10_000; // 10s
const HEALTH_CHECK_DELAY = 3_000; // 3s

async function isRpcHealthy(rpc: RpcUrl, metadata: ChainMetadata): Promise<boolean> {
const builder = protocolToDefaultProviderBuilder[metadata.protocol];
const provider = builder([rpc], metadata.chainId);
if (provider.type === ProviderType.EthersV5)
return isEthersV5ProviderHealthy(provider.provider, metadata);
else if (provider.type === ProviderType.SolanaWeb3)
return isSolanaWeb3ProviderHealthy(provider.provider, metadata);
else if (provider.type === ProviderType.CosmJsWasm || provider.type === ProviderType.CosmJs)
return isCosmJsProviderHealthy(provider.provider, metadata);
else throw new Error(`Unsupported provider type ${provider.type}, new health check required`);
}

async function isEthersV5ProviderHealthy(
provider: EthersV5Provider['provider'],
metadata: ChainMetadata,
): Promise<boolean> {
const chainName = metadata.name;
const blockNumber = await provider.getBlockNumber();
if (!blockNumber || blockNumber < 0) return false;
console.debug(`Block number is okay for ${chainName}`);

if (chainAddresses[chainName]) {
const mailboxAddr = chainAddresses[chainName].mailbox;
const mailbox = Mailbox__factory.createInterface();
const topics = mailbox.encodeFilterTopics(mailbox.events['DispatchId(bytes32)'], []);
console.debug(`Checking mailbox logs for ${chainName}`);
const mailboxLogs = await provider.getLogs({
address: mailboxAddr,
topics,
fromBlock: blockNumber - 99,
toBlock: blockNumber,
});
if (!mailboxLogs) return false;
console.debug(`Mailbox logs okay for ${chainName}`);
}
return true;
}

async function isSolanaWeb3ProviderHealthy(
provider: SolanaWeb3Provider['provider'],
metadata: ChainMetadata,
): Promise<boolean> {
const blockNumber = await provider.getBlockHeight();
if (!blockNumber || blockNumber < 0) return false;
console.debug(`Block number is okay for ${metadata.name}`);
return true;
}

async function isCosmJsProviderHealthy(
provider: CosmJsProvider['provider'] | CosmJsWasmProvider['provider'],
metadata: ChainMetadata,
): Promise<boolean> {
const readyProvider = await provider;
const blockNumber = await readyProvider.getHeight();
if (!blockNumber || blockNumber < 0) return false;
console.debug(`Block number is okay for ${metadata.name}`);
return true;
}

describe('Chain RPC health', async () => {
for (const [chain, metadata] of Object.entries(chainMetadata)) {
metadata.rpcUrls.map((rpc, i) => {
it(`${chain} RPC number ${i} is healthy`, async () => {
const isHealthy = await isRpcHealthy(rpc, metadata);
if (!isHealthy) await sleep(HEALTH_CHECK_DELAY);
expect(isHealthy).to.be.true;
})
.timeout(HEALTH_CHECK_TIMEOUT + HEALTH_CHECK_DELAY * 2)
.retries(3);
});
}
});
2 changes: 1 addition & 1 deletion test/chains.test.ts → test/unit/chains.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChainMetadataSchema } from '@hyperlane-xyz/sdk';
import { z } from 'zod';
import { chainAddresses, chainMetadata } from '../dist/index.js';
import { chainAddresses, chainMetadata } from '../../dist/index.js';

describe('Chain metadata', () => {
for (const [chain, metadata] of Object.entries(chainMetadata)) {
Expand Down

0 comments on commit e96c12a

Please sign in to comment.