Replace Fallback/Retry providers with SmartProvider (#3032)

### Description

- Port `HyperlaneSmartProvider` from explorer
- Use as default ethers v5 provider
- Includes `HyperlaneJsonRpcProvider` and `HyperlaneEtherscanProvider` for more granular control

### Related issues

Fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/2024

### Backward compatibility

No, the RetryProvider was removed

### Testing

Unit tests with Foundry/anvil
E2E tests with Goerli via mocha
E2E tests via metadata-health CI
pull/3058/head
J M Rossy 11 months ago committed by GitHub
parent 79bd348585
commit b832e57aef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      .changeset/tiny-spiders-argue.md
  2. 62
      typescript/infra/src/config/chain.ts
  3. 7
      typescript/sdk/package.json
  4. 20
      typescript/sdk/scripts/foundry-test.sh
  5. 24
      typescript/sdk/src/index.ts
  6. 12
      typescript/sdk/src/providers/MultiProvider.ts
  7. 42
      typescript/sdk/src/providers/RetryProvider.ts
  8. 139
      typescript/sdk/src/providers/SmartProvider/HyperlaneEtherscanProvider.ts
  9. 149
      typescript/sdk/src/providers/SmartProvider/HyperlaneJsonRpcProvider.ts
  10. 27
      typescript/sdk/src/providers/SmartProvider/ProviderMethods.ts
  11. 84
      typescript/sdk/src/providers/SmartProvider/SmartProvider.foundry-test.ts
  12. 206
      typescript/sdk/src/providers/SmartProvider/SmartProvider.test.ts
  13. 372
      typescript/sdk/src/providers/SmartProvider/SmartProvider.ts
  14. 53
      typescript/sdk/src/providers/SmartProvider/types.ts
  15. 62
      typescript/sdk/src/providers/providerBuilders.ts
  16. 1
      typescript/utils/index.ts
  17. 14
      typescript/utils/src/async.ts

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': minor
---
Replace Fallback and Retry Providers with new SmartProvider with more effective fallback/retry logic

@ -2,64 +2,44 @@ import { providers } from 'ethers';
import {
ChainName,
RetryJsonRpcProvider,
RetryProviderOptions,
HyperlaneSmartProvider,
ProviderRetryOptions,
RpcConsensusType,
chainMetadata,
} from '@hyperlane-xyz/sdk';
import { getSecretRpcEndpoint } from '../agents';
import { DeployEnvironment } from './environment';
export const defaultRetry = {
maxRequests: 6,
baseRetryMs: 50,
export const defaultRetry: ProviderRetryOptions = {
maxRetries: 6,
baseRetryDelayMs: 50,
};
function buildProvider(config?: {
url?: string;
network?: providers.Networkish;
retry?: RetryProviderOptions;
}): providers.JsonRpcProvider {
return config?.retry
? new RetryJsonRpcProvider(config.retry, config?.url, config?.network)
: new providers.StaticJsonRpcProvider(config?.url, config?.network);
}
export async function fetchProvider(
environment: DeployEnvironment,
chainName: ChainName,
connectionType: RpcConsensusType = RpcConsensusType.Single,
): Promise<providers.Provider> {
const chainId = chainMetadata[chainName].chainId;
const single = connectionType === RpcConsensusType.Single;
const rpcData = await getSecretRpcEndpoint(environment, chainName, !single);
switch (connectionType) {
case RpcConsensusType.Single: {
return buildProvider({ url: rpcData[0], retry: defaultRetry });
}
case RpcConsensusType.Quorum: {
return new providers.FallbackProvider(
(rpcData as string[]).map((url) => buildProvider({ url })), // disable retry for quorum
);
}
case RpcConsensusType.Fallback: {
return new providers.FallbackProvider(
(rpcData as string[]).map((url, index) => {
const fallbackProviderConfig: providers.FallbackProviderConfig = {
provider: buildProvider({ url, retry: defaultRetry }),
// Priority is used by the FallbackProvider to determine
// how to order providers using ascending ordering.
// When not specified, all providers have the same priority
// and are ordered randomly for each RPC.
priority: index,
};
return fallbackProviderConfig;
}),
1, // a single provider is "quorum", but failure will cause failover to the next provider
if (connectionType === RpcConsensusType.Single) {
return HyperlaneSmartProvider.fromRpcUrl(chainId, rpcData[0], defaultRetry);
} else if (
connectionType === RpcConsensusType.Quorum ||
connectionType === RpcConsensusType.Fallback
) {
return new HyperlaneSmartProvider(
chainId,
rpcData.map((url) => ({ http: url })),
undefined,
// disable retry for quorum
connectionType === RpcConsensusType.Fallback ? defaultRetry : undefined,
);
}
default: {
} else {
throw Error(`Unsupported connectionType: ${connectionType}`);
}
}
}

@ -60,10 +60,11 @@
"lint": "eslint src --ext .ts",
"prepublishOnly": "yarn build",
"prettier": "prettier --write ./src",
"test": "yarn test:unit && yarn test:hardhat",
"test:unit": "mocha --config .mocharc.json './src/**/*.test.ts'",
"test": "yarn test:unit && yarn test:hardhat && yarn test:foundry",
"test:unit": "mocha --config .mocharc.json './src/**/*.test.ts' --exit",
"test:hardhat": "hardhat test $(find ./src -name \"*.hardhat-test.ts\")",
"test:metadata": "ts-node ./src/test/metadata-check.ts"
"test:metadata": "ts-node ./src/test/metadata-check.ts",
"test:foundry": "./scripts/foundry-test.sh"
},
"types": "dist/index.d.ts",
"peerDependencies": {

@ -0,0 +1,20 @@
#!/usr/bin/env bash
function cleanup() {
set +e
pkill -f anvil
rm -rf /tmp/anvil*
set -e
}
cleanup
echo "Starting anvil chain"
anvil --chain-id 31337 -p 8545 --state /tmp/anvil1/state --gas-price 1 > /dev/null &
echo "Running mocha tests"
yarn mocha --config .mocharc.json './src/**/*.foundry-test.ts'
cleanup
echo "Done foundry tests"

@ -242,12 +242,26 @@ export {
ViemTransaction,
ViemTransactionReceipt,
} from './providers/ProviderType';
export { HyperlaneEtherscanProvider } from './providers/SmartProvider/HyperlaneEtherscanProvider';
export { HyperlaneJsonRpcProvider } from './providers/SmartProvider/HyperlaneJsonRpcProvider';
export {
AllProviderMethods,
IProviderMethods,
ProviderMethod,
excludeProviderMethods,
} from './providers/SmartProvider/ProviderMethods';
export { HyperlaneSmartProvider } from './providers/SmartProvider/SmartProvider';
export {
ChainMetadataWithRpcConnectionInfo,
ProviderErrorResult,
ProviderPerformResult,
ProviderRetryOptions,
ProviderStatus,
ProviderSuccessResult,
ProviderTimeoutResult,
SmartProviderOptions,
} from './providers/SmartProvider/types';
export {
RetryJsonRpcProvider,
RetryProviderOptions,
} from './providers/RetryProvider';
export {
DEFAULT_RETRY_OPTIONS,
ProviderBuilderFn,
ProviderBuilderMap,
TypedProviderBuilderFn,

@ -17,11 +17,7 @@ import { ChainMetadataManager } from '../metadata/ChainMetadataManager';
import { ChainMetadata } from '../metadata/chainMetadataTypes';
import { ChainMap, ChainName } from '../types';
import {
DEFAULT_RETRY_OPTIONS,
ProviderBuilderFn,
defaultProviderBuilder,
} from './providerBuilders';
import { ProviderBuilderFn, defaultProviderBuilder } from './providerBuilders';
type Provider = providers.Provider;
@ -91,11 +87,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
31337,
);
} else if (rpcUrls.length) {
this.providers[name] = this.providerBuilder(
rpcUrls,
chainId,
DEFAULT_RETRY_OPTIONS,
);
this.providers[name] = this.providerBuilder(rpcUrls, chainId);
} else {
return null;
}

@ -1,42 +0,0 @@
// RetryProvider Mostly taken from the removed version that was in ethers.js
// See: https://github.com/ethers-io/ethers.js/discussions/3006
import { ethers } from 'ethers';
import { assert, retryAsync } from '@hyperlane-xyz/utils';
export type RetryProviderOptions = {
// Maximum number of times to make the RPC
maxRequests: number;
// Exponential backoff base value
baseRetryMs: number;
};
export class RetryJsonRpcProvider extends ethers.providers
.StaticJsonRpcProvider {
public readonly retryOptions: RetryProviderOptions;
constructor(
retryOptions: RetryProviderOptions,
url?: ethers.utils.ConnectionInfo | string,
network?: ethers.providers.Networkish,
) {
super(url, network);
assert(
retryOptions.maxRequests >= 1,
'RetryOptions.maxRequests must be >= 1',
);
assert(
retryOptions.baseRetryMs >= 1,
'RetryOptions.baseRetryMs must be >= 1',
);
this.retryOptions = retryOptions;
}
async send(method: string, params: Array<any>): Promise<any> {
return retryAsync(
() => super.send(method, params),
this.retryOptions.maxRequests,
this.retryOptions.baseRetryMs,
);
}
}

@ -0,0 +1,139 @@
import debug from 'debug';
import { providers } from 'ethers';
import { objFilter, sleep } from '@hyperlane-xyz/utils';
import { BlockExplorer } from '../../metadata/chainMetadataTypes';
import {
IProviderMethods,
ProviderMethod,
excludeProviderMethods,
} from './ProviderMethods';
// Used for crude rate-limiting of explorer queries without API keys
const hostToLastQueried: Record<string, number> = {};
const ETHERSCAN_THROTTLE_TIME = 6000; // 6.0 seconds
export class HyperlaneEtherscanProvider
extends providers.EtherscanProvider
implements IProviderMethods
{
protected readonly logger = debug('hyperlane:EtherscanProvider');
// Seeing problems with these two methods even though etherscan api claims to support them
public readonly supportedMethods = excludeProviderMethods([
ProviderMethod.Call,
ProviderMethod.EstimateGas,
ProviderMethod.SendTransaction,
]);
constructor(
public readonly explorerConfig: BlockExplorer,
network: providers.Networkish,
) {
super(network, explorerConfig.apiKey);
if (!explorerConfig.apiKey) {
this.logger(
'HyperlaneEtherscanProviders created without an API key will be severely rate limited. Consider using an API key for better reliability.',
);
}
}
getBaseUrl(): string {
if (!this.explorerConfig) return ''; // Constructor net yet finished
const apiUrl = this.explorerConfig?.apiUrl;
if (!apiUrl) throw new Error('Explorer config missing apiUrl');
if (apiUrl.endsWith('/api')) return apiUrl.slice(0, -4);
return apiUrl;
}
getUrl(module: string, params: Record<string, string>): string {
const combinedParams = objFilter(params, (k, v): v is string => !!k && !!v);
combinedParams['module'] = module;
if (this.apiKey) combinedParams['apikey'] = this.apiKey;
const parsedParams = new URLSearchParams(combinedParams);
return `${this.getBaseUrl()}/api?${parsedParams.toString()}`;
}
getPostUrl(): string {
return `${this.getBaseUrl()}/api`;
}
getHostname(): string {
return new URL(this.getBaseUrl()).hostname;
}
getQueryWaitTime(): number {
if (!this.isCommunityResource()) return 0;
const hostname = this.getHostname();
const lastExplorerQuery = hostToLastQueried[hostname] || 0;
return ETHERSCAN_THROTTLE_TIME - (Date.now() - lastExplorerQuery);
}
async fetch(
module: string,
params: Record<string, any>,
post?: boolean,
): Promise<any> {
if (!this.isCommunityResource()) return super.fetch(module, params, post);
const hostname = this.getHostname();
let waitTime = this.getQueryWaitTime();
while (waitTime > 0) {
this.logger(
`HyperlaneEtherscanProvider waiting ${waitTime}ms to avoid rate limit`,
);
await sleep(waitTime);
waitTime = this.getQueryWaitTime();
}
hostToLastQueried[hostname] = Date.now();
return super.fetch(module, params, post);
}
async perform(method: string, params: any, reqId?: number): Promise<any> {
this.logger(
`HyperlaneEtherscanProvider performing method ${method} for reqId ${reqId}`,
);
if (!this.supportedMethods.includes(method as ProviderMethod))
throw new Error(`Unsupported method ${method}`);
if (method === ProviderMethod.GetLogs) {
return this.performGetLogs(params);
} else {
return super.perform(method, params);
}
}
// Overriding to allow more than one topic value
async performGetLogs(params: { filter: providers.Filter }): Promise<any> {
const args: Record<string, any> = { action: 'getLogs' };
if (params.filter.fromBlock)
args.fromBlock = checkLogTag(params.filter.fromBlock);
if (params.filter.toBlock)
args.toBlock = checkLogTag(params.filter.toBlock);
if (params.filter.address) args.address = params.filter.address;
const topics = params.filter.topics;
if (topics?.length) {
if (topics.length > 2)
throw new Error(`Unsupported topic count ${topics.length} (max 2)`);
for (let i = 0; i < topics.length; i++) {
const topic = topics[i];
if (!topic || typeof topic !== 'string' || topic.length !== 66)
throw new Error(`Unsupported topic format: ${topic}`);
args[`topic${i}`] = topic;
if (i < topics.length - 1) args[`topic${i}_${i + 1}_opr`] = 'and';
}
}
return this.fetch('logs', args);
}
}
// From ethers/providers/src.ts/providers/etherscan-provider.ts
function checkLogTag(blockTag: providers.BlockTag): number | 'latest' {
if (typeof blockTag === 'number') return blockTag;
if (blockTag === 'pending') throw new Error('pending not supported');
if (blockTag === 'latest') return blockTag;
return parseInt(blockTag.substring(2), 16);
}

@ -0,0 +1,149 @@
import debug from 'debug';
import { BigNumber, providers, utils } from 'ethers';
import { chunk, isBigNumberish, isNullish } from '@hyperlane-xyz/utils';
import {
AllProviderMethods,
IProviderMethods,
ProviderMethod,
} from './ProviderMethods';
import { RpcConfigWithConnectionInfo } from './types';
const NUM_LOG_BLOCK_RANGES_TO_QUERY = 10;
const NUM_PARALLEL_LOG_QUERIES = 5;
export class HyperlaneJsonRpcProvider
extends providers.StaticJsonRpcProvider
implements IProviderMethods
{
protected readonly logger = debug('hyperlane:JsonRpcProvider');
public readonly supportedMethods = AllProviderMethods;
constructor(
public readonly rpcConfig: RpcConfigWithConnectionInfo,
network: providers.Networkish,
) {
super(rpcConfig.connection ?? rpcConfig.http, network);
}
async perform(method: string, params: any, reqId?: number): Promise<any> {
this.logger(
`HyperlaneJsonRpcProvider performing method ${method} for reqId ${reqId}`,
);
if (method === ProviderMethod.GetLogs) {
return this.performGetLogs(params);
}
const result = await super.perform(method, params);
if (
result === '0x' &&
[
ProviderMethod.Call,
ProviderMethod.GetBalance,
ProviderMethod.GetBlock,
ProviderMethod.GetBlockNumber,
].includes(method as ProviderMethod)
) {
this.logger(`Received 0x result from ${method} for reqId ${reqId}.`);
throw new Error('Invalid response from provider');
}
return result;
}
async performGetLogs(params: { filter: providers.Filter }): Promise<any> {
const superPerform = () => super.perform(ProviderMethod.GetLogs, params);
const paginationOptions = this.rpcConfig.pagination;
if (!paginationOptions || !params.filter) return superPerform();
const { fromBlock, toBlock, address, topics } = params.filter;
const { maxBlockRange, minBlockNumber, maxBlockAge } = paginationOptions;
if (!maxBlockRange && !maxBlockAge && isNullish(minBlockNumber))
return superPerform();
const currentBlockNumber = await super.perform(
ProviderMethod.GetBlockNumber,
null,
);
let endBlock: number;
if (isNullish(toBlock) || toBlock === 'latest') {
endBlock = currentBlockNumber;
} else if (isBigNumberish(toBlock)) {
endBlock = BigNumber.from(toBlock).toNumber();
} else {
return superPerform();
}
let startBlock: number;
if (isNullish(fromBlock) || fromBlock === 'earliest') {
startBlock = 0;
} else if (isBigNumberish(fromBlock)) {
startBlock = BigNumber.from(fromBlock).toNumber();
} else {
return superPerform();
}
if (startBlock > endBlock) {
this.logger(
`Start block ${startBlock} greater than end block. Using ${endBlock} instead`,
);
startBlock = endBlock;
}
const minForBlockRange = maxBlockRange
? endBlock - maxBlockRange * NUM_LOG_BLOCK_RANGES_TO_QUERY + 1
: 0;
if (startBlock < minForBlockRange) {
this.logger(
`Start block ${startBlock} requires too many queries, using ${minForBlockRange}.`,
);
startBlock = minForBlockRange;
}
const minForBlockAge = maxBlockAge ? currentBlockNumber - maxBlockAge : 0;
if (startBlock < minForBlockAge) {
this.logger(
`Start block ${startBlock} below max block age, increasing to ${minForBlockAge}`,
);
startBlock = minForBlockAge;
}
if (minBlockNumber && startBlock < minBlockNumber) {
this.logger(
`Start block ${startBlock} below config min, increasing to ${minBlockNumber}`,
);
startBlock = minBlockNumber;
}
const blockChunkRange = maxBlockRange || endBlock - startBlock;
const blockChunks: [number, number][] = [];
for (let from = startBlock; from <= endBlock; from += blockChunkRange) {
const to = Math.min(from + blockChunkRange - 1, endBlock);
blockChunks.push([from, to]);
}
let combinedResults: Array<providers.Log> = [];
const requestChunks = chunk(blockChunks, NUM_PARALLEL_LOG_QUERIES);
for (const reqChunk of requestChunks) {
const resultPromises = reqChunk.map(
(blockChunk) =>
super.perform(ProviderMethod.GetLogs, {
filter: {
address,
topics,
fromBlock: utils.hexValue(BigNumber.from(blockChunk[0])),
toBlock: utils.hexValue(BigNumber.from(blockChunk[1])),
},
}) as Promise<Array<providers.Log>>,
);
const results = await Promise.all(resultPromises);
combinedResults = [...combinedResults, ...results.flat()];
}
return combinedResults;
}
getBaseUrl(): string {
return this.connection.url;
}
}

@ -0,0 +1,27 @@
export interface IProviderMethods {
readonly supportedMethods: ProviderMethod[];
}
export enum ProviderMethod {
Call = 'call',
EstimateGas = 'estimateGas',
GetBalance = 'getBalance',
GetBlock = 'getBlock',
GetBlockNumber = 'getBlockNumber',
GetCode = 'getCode',
GetGasPrice = 'getGasPrice',
GetStorageAt = 'getStorageAt',
GetTransaction = 'getTransaction',
GetTransactionCount = 'getTransactionCount',
GetTransactionReceipt = 'getTransactionReceipt',
GetLogs = 'getLogs',
SendTransaction = 'sendTransaction',
}
export const AllProviderMethods = Object.values(ProviderMethod);
export function excludeProviderMethods(
exclude: ProviderMethod[],
): ProviderMethod[] {
return AllProviderMethods.filter((m) => !exclude.includes(m));
}

@ -0,0 +1,84 @@
import { expect } from 'chai';
import { Wallet, constants } from 'ethers';
import { ERC20__factory } from '@hyperlane-xyz/core';
import { HyperlaneSmartProvider } from './SmartProvider';
const PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const NETWORK = 31337;
const URL = 'http://127.0.0.1:8545';
describe('SmartProvider', async () => {
let signer: Wallet;
let smartProvider: HyperlaneSmartProvider;
let contractAddress: string;
before(async () => {
smartProvider = HyperlaneSmartProvider.fromRpcUrl(NETWORK, URL, {
maxRetries: 3,
});
signer = new Wallet(PK, smartProvider);
});
it('Sends transactions', async () => {
const transferTx = await signer.populateTransaction({
to: signer.address,
value: 1,
});
const signedTx = await signer.signTransaction(transferTx);
const response = await smartProvider.sendTransaction(signedTx);
expect(response.hash.substring(0, 2)).to.equal('0x');
expect(response.hash.length).to.equal(66);
});
it('Deploys contracts', async () => {
const factory = new ERC20__factory(signer);
const contract = await factory.deploy('fake', 'FAKE');
contractAddress = contract.address;
expect(contractAddress.substring(0, 2)).to.equal('0x');
expect(contractAddress.length).to.equal(42);
});
it('Handles multiple requests', async () => {
const [
isHealthy,
blockNum,
block,
balance,
gasPrice,
feeData,
code,
txCount,
network,
logs,
] = await Promise.all([
smartProvider.isHealthy(),
smartProvider.getBlockNumber(),
smartProvider.getBlock(1),
smartProvider.getBalance(signer.address),
smartProvider.getGasPrice(),
smartProvider.getFeeData(),
smartProvider.getCode(contractAddress),
smartProvider.getTransactionCount(signer.address),
smartProvider.getNetwork(),
smartProvider.getLogs({
fromBlock: 0,
address: constants.AddressZero,
topics: [],
}),
]);
expect(isHealthy).to.be.true;
expect(blockNum).to.greaterThan(0);
expect(block.number).to.equal(1);
expect(balance.toBigInt() > 0).to.be.true;
expect(gasPrice.toBigInt() > 0).to.be.true;
expect(feeData.maxFeePerGas && feeData.maxFeePerGas.toBigInt() > 0).to.be
.true;
expect(code.length).to.greaterThan(10);
expect(txCount).to.be.greaterThan(0);
expect(network.chainId).to.equal(NETWORK);
expect(Array.isArray(logs)).to.be.true;
});
});

@ -0,0 +1,206 @@
/* eslint-disable no-console */
import { expect } from 'chai';
import { ethers } from 'ethers';
import { eqAddress } from '@hyperlane-xyz/utils';
import { chainMetadata } from '../../consts/chainMetadata';
import { ChainMetadata } from '../../metadata/chainMetadataTypes';
import { ProviderMethod } from './ProviderMethods';
import { HyperlaneSmartProvider } from './SmartProvider';
const MIN_BLOCK_NUM = 10000000;
const DEFAULT_ACCOUNT = '0x9d525E28Fe5830eE92d7Aa799c4D21590567B595';
const WETH_CONTRACT = '0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6';
const WETH_TRANSFER_TOPIC0 =
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
const WETH_TRANSFER_TOPIC1 =
'0x0000000000000000000000004648a43b2c14da09fdf82b161150d3f634f40491';
const WETH_CALL_DATA =
'0x70a082310000000000000000000000004f7a67464b5976d7547c860109e4432d50afb38e';
const TRANSFER_TX_HASH =
'0x45a586f90ffd5d0f8e618f0f3703b14c2c9e4611af6231d6fed32c62776b6c1b';
const goerliRpcConfig = {
...chainMetadata.goerli.rpcUrls[0],
pagination: {
maxBlockRange: 1000,
minBlockNumber: MIN_BLOCK_NUM,
},
};
const justExplorersConfig: ChainMetadata = {
...chainMetadata.goerli,
rpcUrls: [] as any,
};
const justRpcsConfig: ChainMetadata = {
...chainMetadata.goerli,
rpcUrls: [goerliRpcConfig],
blockExplorers: [],
};
const combinedConfig: ChainMetadata = {
...chainMetadata.goerli,
rpcUrls: [goerliRpcConfig],
};
const configs: [string, ChainMetadata][] = [
['Just Explorers', justExplorersConfig],
['Just RPCs', justRpcsConfig],
['Combined configs', combinedConfig],
];
describe('SmartProvider', () => {
let provider: HyperlaneSmartProvider;
const itDoesIfSupported = (method: ProviderMethod, fn: () => any) => {
it(method, () => {
if (provider.supportedMethods.includes(method)) {
return fn();
}
}).timeout(20_000);
};
for (const [description, config] of configs) {
describe(description, () => {
provider = HyperlaneSmartProvider.fromChainMetadata(config);
itDoesIfSupported(ProviderMethod.GetBlock, async () => {
const latestBlock = await provider.getBlock('latest');
console.debug('Latest block #', latestBlock.number);
expect(latestBlock.number).to.be.greaterThan(MIN_BLOCK_NUM);
expect(latestBlock.timestamp).to.be.greaterThan(
Date.now() / 1000 - 60 * 60 * 24,
);
const firstBlock = await provider.getBlock(1);
expect(firstBlock.number).to.equal(1);
});
itDoesIfSupported(ProviderMethod.GetBlockNumber, async () => {
const result = await provider.getBlockNumber();
console.debug('Latest block #', result);
expect(result).to.be.greaterThan(MIN_BLOCK_NUM);
});
itDoesIfSupported(ProviderMethod.GetGasPrice, async () => {
const result = await provider.getGasPrice();
console.debug('Gas price', result.toString());
expect(result.toNumber()).to.be.greaterThan(0);
});
itDoesIfSupported(ProviderMethod.GetBalance, async () => {
const result = await provider.getBalance(DEFAULT_ACCOUNT);
console.debug('Balance', result.toString());
expect(parseFloat(ethers.utils.formatEther(result))).to.be.greaterThan(
1,
);
});
itDoesIfSupported(ProviderMethod.GetCode, async () => {
const result = await provider.getCode(WETH_CONTRACT);
console.debug('Weth code snippet', result.substring(0, 12));
expect(result.length).to.be.greaterThan(100);
});
itDoesIfSupported(ProviderMethod.GetStorageAt, async () => {
const result = await provider.getStorageAt(WETH_CONTRACT, 0);
console.debug('Weth storage', result);
expect(result.length).to.be.greaterThan(20);
});
itDoesIfSupported(ProviderMethod.GetTransactionCount, async () => {
const result = await provider.getTransactionCount(
DEFAULT_ACCOUNT,
'latest',
);
console.debug('Tx Count', result);
expect(result).to.be.greaterThan(40);
});
itDoesIfSupported(ProviderMethod.GetTransaction, async () => {
const result = await provider.getTransaction(TRANSFER_TX_HASH);
console.debug('Transaction confirmations', result.confirmations);
expect(result.confirmations).to.be.greaterThan(1000);
});
itDoesIfSupported(ProviderMethod.GetTransactionReceipt, async () => {
const result = await provider.getTransactionReceipt(TRANSFER_TX_HASH);
console.debug('Transaction receipt', result.confirmations);
expect(result.confirmations).to.be.greaterThan(1000);
});
itDoesIfSupported(ProviderMethod.GetLogs, async () => {
console.debug('Testing logs with no from/to range');
const result1 = await provider.getLogs({
address: WETH_CONTRACT,
topics: [WETH_TRANSFER_TOPIC0, WETH_TRANSFER_TOPIC1],
});
console.debug('Logs found', result1.length);
expect(result1.length).to.be.greaterThan(0);
expect(eqAddress(result1[0].address, WETH_CONTRACT)).to.be.true;
console.debug('Testing logs with small from/to range');
const result2 = await provider.getLogs({
address: WETH_CONTRACT,
topics: [WETH_TRANSFER_TOPIC0],
fromBlock: MIN_BLOCK_NUM,
toBlock: MIN_BLOCK_NUM + 100,
});
expect(result2.length).to.be.greaterThan(0);
expect(eqAddress(result2[0].address, WETH_CONTRACT)).to.be.true;
console.debug('Testing logs with large from/to range');
const result3 = await provider.getLogs({
address: WETH_CONTRACT,
topics: [WETH_TRANSFER_TOPIC0, WETH_TRANSFER_TOPIC1],
fromBlock: MIN_BLOCK_NUM,
toBlock: 'latest',
});
expect(result3.length).to.be.greaterThan(0);
expect(eqAddress(result3[0].address, WETH_CONTRACT)).to.be.true;
});
itDoesIfSupported(ProviderMethod.EstimateGas, async () => {
const result = await provider.estimateGas({
to: DEFAULT_ACCOUNT,
from: DEFAULT_ACCOUNT,
value: 1,
});
expect(result.toNumber()).to.be.greaterThan(10_000);
});
itDoesIfSupported(ProviderMethod.Call, async () => {
const result = await provider.call({
to: WETH_CONTRACT,
from: DEFAULT_ACCOUNT,
data: WETH_CALL_DATA,
});
expect(result).to.equal(
'0x0000000000000000000000000000000000000000000000000000000000000000',
);
});
it('Handles parallel requests', async () => {
const result1Promise = provider.getLogs({
address: WETH_CONTRACT,
topics: [WETH_TRANSFER_TOPIC0],
fromBlock: MIN_BLOCK_NUM,
toBlock: MIN_BLOCK_NUM + 100,
});
const result2Promise = provider.getBlockNumber();
const result3Promise = provider.getTransaction(TRANSFER_TX_HASH);
const [result1, result2, result3] = await Promise.all([
result1Promise,
result2Promise,
result3Promise,
]);
expect(result1.length).to.be.greaterThan(0);
expect(result2).to.be.greaterThan(0);
expect(!!result3).to.be.true;
}).timeout(10_000);
});
it('Reports as healthy', async () => {
const result = await provider.isHealthy();
expect(result).to.be.true;
});
}
});

@ -0,0 +1,372 @@
import debug from 'debug';
import { providers } from 'ethers';
import {
raceWithContext,
retryAsync,
runWithTimeout,
sleep,
} from '@hyperlane-xyz/utils';
import {
BlockExplorer,
ChainMetadata,
ExplorerFamily,
RpcUrl,
} from '../../metadata/chainMetadataTypes';
import { HyperlaneEtherscanProvider } from './HyperlaneEtherscanProvider';
import { HyperlaneJsonRpcProvider } from './HyperlaneJsonRpcProvider';
import { IProviderMethods, ProviderMethod } from './ProviderMethods';
import {
ChainMetadataWithRpcConnectionInfo,
ProviderPerformResult,
ProviderStatus,
ProviderTimeoutResult,
SmartProviderOptions,
} from './types';
const DEFAULT_MAX_RETRIES = 1;
const DEFAULT_BASE_RETRY_DELAY_MS = 250; // 0.25 seconds
const DEFAULT_STAGGER_DELAY_MS = 1000; // 1 seconds
type HyperlaneProvider = HyperlaneEtherscanProvider | HyperlaneJsonRpcProvider;
export class HyperlaneSmartProvider
extends providers.BaseProvider
implements IProviderMethods
{
protected readonly logger = debug('hyperlane:SmartProvider');
// TODO also support blockscout here
public readonly explorerProviders: HyperlaneEtherscanProvider[];
public readonly rpcProviders: HyperlaneJsonRpcProvider[];
public readonly supportedMethods: ProviderMethod[];
public requestCount = 0;
constructor(
network: providers.Networkish,
rpcUrls?: RpcUrl[],
blockExplorers?: BlockExplorer[],
public readonly options?: SmartProviderOptions,
) {
super(network);
const supportedMethods = new Set<ProviderMethod>();
if (!rpcUrls?.length && !blockExplorers?.length)
throw new Error('At least one RPC URL or block explorer is required');
if (blockExplorers?.length) {
this.explorerProviders = blockExplorers
.map((explorerConfig) => {
if (
!explorerConfig.family ||
explorerConfig.family === ExplorerFamily.Etherscan
) {
const newProvider = new HyperlaneEtherscanProvider(
explorerConfig,
network,
);
newProvider.supportedMethods.forEach((m) =>
supportedMethods.add(m),
);
return newProvider;
// TODO also support blockscout here
} else return null;
})
.filter((e): e is HyperlaneEtherscanProvider => !!e);
} else {
this.explorerProviders = [];
}
if (rpcUrls?.length) {
this.rpcProviders = rpcUrls.map((rpcConfig) => {
const newProvider = new HyperlaneJsonRpcProvider(rpcConfig, network);
newProvider.supportedMethods.forEach((m) => supportedMethods.add(m));
return newProvider;
});
} else {
this.rpcProviders = [];
}
this.supportedMethods = [...supportedMethods.values()];
}
static fromChainMetadata(
chainMetadata: ChainMetadataWithRpcConnectionInfo,
options?: SmartProviderOptions,
): HyperlaneSmartProvider {
const network = chainMetadataToProviderNetwork(chainMetadata);
return new HyperlaneSmartProvider(
network,
chainMetadata.rpcUrls,
chainMetadata.blockExplorers,
options,
);
}
static fromRpcUrl(
network: providers.Networkish,
rpcUrl: string,
options?: SmartProviderOptions,
): HyperlaneSmartProvider {
return new HyperlaneSmartProvider(
network,
[{ http: rpcUrl }],
undefined,
options,
);
}
async detectNetwork(): Promise<providers.Network> {
// For simplicity, efficiency, and better compat with new networks, this assumes
// the provided RPC urls are correct and returns static data here instead of
// querying each sub-provider for network info
return this.network;
}
async perform(method: string, params: { [name: string]: any }): Promise<any> {
const allProviders = [...this.explorerProviders, ...this.rpcProviders];
if (!allProviders.length) throw new Error('No providers available');
const supportedProviders = allProviders.filter((p) =>
p.supportedMethods.includes(method as ProviderMethod),
);
if (!supportedProviders.length)
throw new Error(`No providers available for method ${method}`);
this.requestCount += 1;
const reqId = this.requestCount;
return retryAsync(
() => this.performWithFallback(method, params, supportedProviders, reqId),
this.options?.maxRetries || DEFAULT_MAX_RETRIES,
this.options?.baseRetryDelayMs || DEFAULT_BASE_RETRY_DELAY_MS,
);
}
/**
* Checks if this SmartProvider is healthy by checking for new blocks
* @param numBlocks The number of sequential blocks to check for. Default 1
* @param timeoutMs The maximum time to wait for the full check. Default 3000ms
* @returns true if the provider is healthy, false otherwise
*/
async isHealthy(numBlocks = 1, timeoutMs = 3_000): Promise<boolean> {
try {
await runWithTimeout(timeoutMs, async () => {
let previousBlockNumber = 0;
let i = 1;
while (i <= numBlocks) {
const block = await this.getBlock('latest');
if (block.number > previousBlockNumber) {
i += 1;
previousBlockNumber = block.number;
} else {
await sleep(500);
}
}
return true;
});
return true;
} catch (error) {
this.logger('Provider is unhealthy', error);
return false;
}
}
isExplorerProvider(p: HyperlaneProvider): p is HyperlaneEtherscanProvider {
return this.explorerProviders.includes(p as any);
}
/**
* This perform method will trigger any providers that support the method
* one at a time in preferential order. If one is slow to respond, the next is triggered.
* TODO: Consider adding a quorum option that requires a certain number of providers to agree
*/
protected async performWithFallback(
method: string,
params: { [name: string]: any },
providers: Array<HyperlaneEtherscanProvider | HyperlaneJsonRpcProvider>,
reqId: number,
): Promise<any> {
let pIndex = 0;
const providerResultPromises: Promise<ProviderPerformResult>[] = [];
const providerResultErrors: unknown[] = [];
while (true) {
// Trigger the next provider in line
if (pIndex < providers.length) {
const provider = providers[pIndex];
const providerUrl = provider.getBaseUrl();
const isLastProvider = pIndex === providers.length - 1;
// Skip the explorer provider if it's currently in a cooldown period
if (
this.isExplorerProvider(provider) &&
provider.getQueryWaitTime() > 0 &&
!isLastProvider &&
method !== ProviderMethod.GetLogs // never skip GetLogs
) {
pIndex += 1;
continue;
}
const resultPromise = this.wrapProviderPerform(
provider,
providerUrl,
method,
params,
reqId,
);
const timeoutPromise = timeoutResult(
this.options?.fallbackStaggerMs || DEFAULT_STAGGER_DELAY_MS,
);
const result = await Promise.race([resultPromise, timeoutPromise]);
if (result.status === ProviderStatus.Success) {
return result.value;
} else if (result.status === ProviderStatus.Timeout) {
this.logger(
`Slow response from provider using ${providerUrl}.${
!isLastProvider ? ' Triggering next provider.' : ''
}`,
);
providerResultPromises.push(resultPromise);
pIndex += 1;
} else if (result.status === ProviderStatus.Error) {
this.logger(
`Error from provider using ${providerUrl}.${
!isLastProvider ? ' Triggering next provider.' : ''
}`,
);
providerResultErrors.push(result.error);
pIndex += 1;
} else {
throw new Error('Unexpected result from provider');
}
// All providers already triggered, wait for one to complete or all to fail/timeout
} else if (providerResultPromises.length > 0) {
const timeoutPromise = timeoutResult(
this.options?.fallbackStaggerMs || DEFAULT_STAGGER_DELAY_MS,
20,
);
const resultPromise = this.waitForProviderSuccess(
providerResultPromises,
);
const result = await Promise.race([resultPromise, timeoutPromise]);
if (result.status === ProviderStatus.Success) {
return result.value;
} else if (result.status === ProviderStatus.Timeout) {
this.throwCombinedProviderErrors(
providerResultErrors,
`All providers timed out for method ${method}`,
);
} else if (result.status === ProviderStatus.Error) {
this.throwCombinedProviderErrors(
[result.error, ...providerResultErrors],
`All providers failed for method ${method}`,
);
} else {
throw new Error('Unexpected result from provider');
}
// All providers have already failed, all hope is lost
} else {
this.throwCombinedProviderErrors(
providerResultErrors,
`All providers failed for method ${method}`,
);
}
}
}
// Warp for additional logging and error handling
protected async wrapProviderPerform(
provider: HyperlaneProvider,
providerUrl: string,
method: string,
params: any,
reqId: number,
): Promise<ProviderPerformResult> {
try {
this.logger(
`Provider using ${providerUrl} performing method ${method} for reqId ${reqId}`,
);
const result = await provider.perform(method, params, reqId);
return { status: ProviderStatus.Success, value: result };
} catch (error) {
this.logger(
`Error performing ${method} on provider ${providerUrl} for reqId ${reqId}`,
error,
);
return { status: ProviderStatus.Error, error };
}
}
// Returns the first success from a list a promises, or an error if all fail
protected async waitForProviderSuccess(
resultPromises: Promise<ProviderPerformResult>[],
): Promise<ProviderPerformResult> {
const combinedErrors: unknown[] = [];
const resolvedPromises = new Set<Promise<ProviderPerformResult>>();
while (resolvedPromises.size < resultPromises.length) {
const unresolvedPromises = resultPromises.filter(
(p) => !resolvedPromises.has(p),
);
const winner = await raceWithContext(unresolvedPromises);
resolvedPromises.add(winner.promise);
const result = winner.resolved;
if (result.status === ProviderStatus.Success) {
return result;
} else if (result.status === ProviderStatus.Error) {
combinedErrors.push(result.error);
} else {
return {
status: ProviderStatus.Error,
error: new Error('Unexpected result format from provider'),
};
}
}
// If reached, all providers finished unsuccessfully
return {
status: ProviderStatus.Error,
// TODO combine errors
error: combinedErrors.length
? combinedErrors[0]
: new Error('Unknown error from provider'),
};
}
protected throwCombinedProviderErrors(
errors: unknown[],
fallbackMsg: string,
): void {
this.logger(fallbackMsg);
// TODO inspect the errors in some clever way to choose which to throw
if (errors.length > 0) throw errors[0];
else throw new Error(fallbackMsg);
}
}
function chainMetadataToProviderNetwork(
chainMetadata: ChainMetadata | ChainMetadataWithRpcConnectionInfo,
): providers.Network {
return {
name: chainMetadata.name,
chainId: chainMetadata.chainId as number,
// @ts-ignore add ensAddress to ChainMetadata
ensAddress: chainMetadata.ensAddress,
};
}
function timeoutResult(staggerDelay: number, multiplier = 1) {
return new Promise<ProviderTimeoutResult>((resolve) =>
setTimeout(
() =>
resolve({
status: ProviderStatus.Timeout,
}),
staggerDelay * multiplier,
),
);
}

@ -0,0 +1,53 @@
import type { utils } from 'ethers';
import { ChainMetadata, RpcUrl } from '../../metadata/chainMetadataTypes';
export type RpcConfigWithConnectionInfo = RpcUrl & {
connection?: utils.ConnectionInfo;
};
export interface ChainMetadataWithRpcConnectionInfo
extends Omit<ChainMetadata, 'rpcUrls'> {
rpcUrls: Array<RpcConfigWithConnectionInfo>;
}
export enum ProviderStatus {
Success = 'success',
Error = 'error',
Timeout = 'timeout',
}
export interface ProviderPerformResultBase {
status: ProviderStatus;
}
export interface ProviderSuccessResult extends ProviderPerformResultBase {
status: ProviderStatus.Success;
value: any;
}
export interface ProviderErrorResult extends ProviderPerformResultBase {
status: ProviderStatus.Error;
error: unknown;
}
export interface ProviderTimeoutResult extends ProviderPerformResultBase {
status: ProviderStatus.Timeout;
}
export type ProviderPerformResult =
| ProviderSuccessResult
| ProviderErrorResult
| ProviderTimeoutResult;
export interface ProviderRetryOptions {
// Maximum number of times to make the re-query the RPC/explorer
maxRetries?: number;
// Exponential backoff base value for retries
baseRetryDelayMs?: number;
}
export interface SmartProviderOptions extends ProviderRetryOptions {
// The time to wait before attempting the next provider
fallbackStaggerMs?: number;
}

@ -6,7 +6,7 @@ import { createPublicClient, http } from 'viem';
import { ProtocolType, isNumeric } from '@hyperlane-xyz/utils';
import { ChainMetadata } from '../metadata/chainMetadataTypes';
import { ChainMetadata, RpcUrl } from '../metadata/chainMetadataTypes';
import {
CosmJsProvider,
@ -17,56 +17,37 @@ import {
TypedProvider,
ViemProvider,
} from './ProviderType';
import { RetryJsonRpcProvider, RetryProviderOptions } from './RetryProvider';
import { HyperlaneSmartProvider } from './SmartProvider/SmartProvider';
import { ProviderRetryOptions } from './SmartProvider/types';
export type ProviderBuilderFn<P> = (
rpcUrls: ChainMetadata['rpcUrls'],
network: number | string,
retryOverride?: RetryProviderOptions,
retryOverride?: ProviderRetryOptions,
) => P;
export type TypedProviderBuilderFn = ProviderBuilderFn<TypedProvider>;
export const DEFAULT_RETRY_OPTIONS: RetryProviderOptions = {
maxRequests: 3,
baseRetryMs: 250,
const DEFAULT_RETRY_OPTIONS: ProviderRetryOptions = {
maxRetries: 3,
baseRetryDelayMs: 250,
};
export function defaultEthersV5ProviderBuilder(
rpcUrls: ChainMetadata['rpcUrls'],
rpcUrls: RpcUrl[],
network: number | string,
retryOverride?: RetryProviderOptions,
retryOverride?: ProviderRetryOptions,
): EthersV5Provider {
const createProvider = (r: ChainMetadata['rpcUrls'][number]) => {
const retry = r.retry || retryOverride;
return retry
? new RetryJsonRpcProvider(retry, r.http, network)
: new providers.StaticJsonRpcProvider(r.http, network);
};
let provider: providers.Provider;
if (rpcUrls.length > 1) {
provider = new providers.FallbackProvider(rpcUrls.map(createProvider), 1);
} else if (rpcUrls.length === 1) {
provider = createProvider(rpcUrls[0]);
} else {
throw new Error('No RPC URLs provided');
}
const provider = new HyperlaneSmartProvider(
network,
rpcUrls,
undefined,
retryOverride || DEFAULT_RETRY_OPTIONS,
);
return { type: ProviderType.EthersV5, provider };
}
// export function defaultEthersV6ProviderBuilder(
// rpcUrls: ChainMetadata['rpcUrls'],
// network: number | string,
// ): EthersV6Provider {
// // TODO add support for retry providers here
// if (!rpcUrls.length) throw new Error('No RPC URLs provided');
// return {
// type: ProviderType.EthersV6,
// provider: new Ev6JsonRpcProvider(rpcUrls[0].http, network),
// };
// }
export function defaultViemProviderBuilder(
rpcUrls: ChainMetadata['rpcUrls'],
rpcUrls: RpcUrl[],
network: number | string,
): ViemProvider {
if (!rpcUrls.length) throw new Error('No RPC URLs provided');
@ -88,7 +69,7 @@ export function defaultViemProviderBuilder(
}
export function defaultSolProviderBuilder(
rpcUrls: ChainMetadata['rpcUrls'],
rpcUrls: RpcUrl[],
_network: number | string,
): SolanaWeb3Provider {
if (!rpcUrls.length) throw new Error('No RPC URLs provided');
@ -99,7 +80,7 @@ export function defaultSolProviderBuilder(
}
export function defaultFuelProviderBuilder(
rpcUrls: ChainMetadata['rpcUrls'],
rpcUrls: RpcUrl[],
_network: number | string,
): EthersV5Provider {
if (!rpcUrls.length) throw new Error('No RPC URLs provided');
@ -107,7 +88,7 @@ export function defaultFuelProviderBuilder(
}
export function defaultCosmJsProviderBuilder(
rpcUrls: ChainMetadata['rpcUrls'],
rpcUrls: RpcUrl[],
_network: number | string,
): CosmJsProvider {
if (!rpcUrls.length) throw new Error('No RPC URLs provided');
@ -118,7 +99,7 @@ export function defaultCosmJsProviderBuilder(
}
export function defaultCosmJsWasmProviderBuilder(
rpcUrls: ChainMetadata['rpcUrls'],
rpcUrls: RpcUrl[],
_network: number | string,
): CosmJsWasmProvider {
if (!rpcUrls.length) throw new Error('No RPC URLs provided');
@ -130,7 +111,7 @@ export function defaultCosmJsWasmProviderBuilder(
// Kept for backwards compatibility
export function defaultProviderBuilder(
rpcUrls: ChainMetadata['rpcUrls'],
rpcUrls: RpcUrl[],
_network: number | string,
): providers.Provider {
return defaultEthersV5ProviderBuilder(rpcUrls, _network).provider;
@ -142,7 +123,6 @@ export type ProviderBuilderMap = Record<
>;
export const defaultProviderBuilderMap: ProviderBuilderMap = {
[ProviderType.EthersV5]: defaultEthersV5ProviderBuilder,
// [ProviderType.EthersV6]: defaultEthersV6ProviderBuilder,
[ProviderType.Viem]: defaultViemProviderBuilder,
[ProviderType.SolanaWeb3]: defaultSolProviderBuilder,
[ProviderType.CosmJs]: defaultCosmJsProviderBuilder,

@ -48,6 +48,7 @@ export {
export { chunk, exclude } from './src/arrays';
export {
pollAsync,
raceWithContext,
retryAsync,
runWithTimeout,
sleep,

@ -102,3 +102,17 @@ export async function pollAsync<T>(
}
throw saveError;
}
/**
* An enhanced Promise.race that returns
* objects with the promise itself and index
* instead of just the resolved value.
*/
export async function raceWithContext<T>(
promises: Array<Promise<T>>,
): Promise<{ resolved: T; promise: Promise<T>; index: number }> {
const promisesWithContext = promises.map((p, i) =>
p.then((resolved) => ({ resolved, promise: p, index: i })),
);
return Promise.race(promisesWithContext);
}

Loading…
Cancel
Save