Skip to content

Commit

Permalink
Replace Fallback/Retry providers with SmartProvider (#3032)
Browse files Browse the repository at this point in the history
### Description

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

### Related issues

Fixes #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
  • Loading branch information
jmrossy authored Dec 13, 2023
1 parent 79bd348 commit b832e57
Show file tree
Hide file tree
Showing 17 changed files with 1,139 additions and 144 deletions.
5 changes: 5 additions & 0 deletions .changeset/tiny-spiders-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': minor
---

Replace Fallback and Retry Providers with new SmartProvider with more effective fallback/retry logic
66 changes: 23 additions & 43 deletions typescript/infra/src/config/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
default: {
throw Error(`Unsupported connectionType: ${connectionType}`);
}

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,
);
} else {
throw Error(`Unsupported connectionType: ${connectionType}`);
}
}
7 changes: 4 additions & 3 deletions typescript/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
20 changes: 20 additions & 0 deletions typescript/sdk/scripts/foundry-test.sh
Original file line number Diff line number Diff line change
@@ -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"
24 changes: 19 additions & 5 deletions typescript/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 2 additions & 10 deletions typescript/sdk/src/providers/MultiProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
42 changes: 0 additions & 42 deletions typescript/sdk/src/providers/RetryProvider.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit b832e57

Please sign in to comment.