@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
327 lines • 15.2 kB
JavaScript
import { BigNumber, errors as EthersError, providers, utils } from 'ethers';
import { raceWithContext, retryAsync, rootLogger, runWithTimeout, sleep, } from '@hyperlane-xyz/utils';
import { ExplorerFamily, } from '../../metadata/chainMetadataTypes.js';
import { HyperlaneEtherscanProvider } from './HyperlaneEtherscanProvider.js';
import { HyperlaneJsonRpcProvider } from './HyperlaneJsonRpcProvider.js';
import { ProviderMethod } from './ProviderMethods.js';
import { ProviderStatus, } from './types.js';
export function getSmartProviderErrorMessage(errorMsg) {
return `${errorMsg}: RPC request failed. Check RPC validity. To override RPC URLs, see: https://docs.hyperlane.xyz/docs/deploy-hyperlane-troubleshooting#override-rpc-urls`;
}
// This is a partial list. If needed, check the full list for more: https://docs.ethers.org/v5/api/utils/logger/#errors
const RPC_SERVER_ERRORS = [
EthersError.NETWORK_ERROR,
EthersError.NOT_IMPLEMENTED,
EthersError.SERVER_ERROR,
EthersError.TIMEOUT,
EthersError.UNKNOWN_ERROR,
EthersError.UNSUPPORTED_OPERATION,
];
const RPC_BLOCKCHAIN_ERRORS = [
EthersError.CALL_EXCEPTION,
EthersError.INSUFFICIENT_FUNDS,
EthersError.NONCE_EXPIRED,
EthersError.REPLACEMENT_UNDERPRICED,
EthersError.TRANSACTION_REPLACED,
EthersError.UNPREDICTABLE_GAS_LIMIT,
];
const DEFAULT_MAX_RETRIES = 1;
const DEFAULT_BASE_RETRY_DELAY_MS = 250; // 0.25 seconds
const DEFAULT_STAGGER_DELAY_MS = 1000; // 1 seconds
export class HyperlaneSmartProvider extends providers.BaseProvider {
options;
logger;
// TODO also support blockscout here
explorerProviders;
rpcProviders;
supportedMethods;
requestCount = 0;
constructor(network, rpcUrls, blockExplorers, options) {
super(network);
this.options = options;
const supportedMethods = new Set();
this.logger = rootLogger.child({
module: `SmartProvider:${this.network.chainId}`,
});
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);
}
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()];
}
setLogLevel(level) {
this.logger.level = level;
}
async getPriorityFee() {
try {
return BigNumber.from(await this.perform('maxPriorityFeePerGas', {}));
}
catch {
return BigNumber.from('1500000000');
}
}
async getFeeData() {
// override hardcoded getFeedata
// Copied from https://github.com/ethers-io/ethers.js/blob/v5/packages/abstract-provider/src.ts/index.ts#L235 which SmartProvider inherits this logic from
const { block, gasPrice } = await utils.resolveProperties({
block: this.getBlock('latest'),
gasPrice: this.getGasPrice().catch(() => {
return null;
}),
});
let lastBaseFeePerGas = null, maxFeePerGas = null, maxPriorityFeePerGas = null;
if (block?.baseFeePerGas) {
// We may want to compute this more accurately in the future,
// using the formula "check if the base fee is correct".
// See: https://eips.ethereum.org/EIPS/eip-1559
lastBaseFeePerGas = block.baseFeePerGas;
maxPriorityFeePerGas = await this.getPriorityFee();
maxFeePerGas = block.baseFeePerGas.mul(2).add(maxPriorityFeePerGas);
}
return { lastBaseFeePerGas, maxFeePerGas, maxPriorityFeePerGas, gasPrice };
}
static fromChainMetadata(chainMetadata, options) {
const network = chainMetadataToProviderNetwork(chainMetadata);
return new HyperlaneSmartProvider(network, chainMetadata.rpcUrls, chainMetadata.blockExplorers, options);
}
static fromRpcUrl(network, rpcUrl, options) {
return new HyperlaneSmartProvider(network, [{ http: rpcUrl }], undefined, options);
}
async detectNetwork() {
// 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, params) {
const allProviders = [...this.explorerProviders, ...this.rpcProviders];
if (!allProviders.length)
throw new Error('No providers available');
const supportedProviders = allProviders.filter((p) => p.supportedMethods.includes(method));
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 = 3000) {
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.error('Provider is unhealthy', error);
return false;
}
}
isExplorerProvider(p) {
return this.explorerProviders.includes(p);
}
/**
* 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
*/
async performWithFallback(method, params, providers, reqId) {
let pIndex = 0;
const providerResultPromises = [];
const providerResultErrors = [];
while (true) {
// Trigger the next provider in line
if (pIndex < providers.length) {
const provider = providers[pIndex];
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, pIndex, method, params, reqId);
const timeoutPromise = timeoutResult(this.options?.fallbackStaggerMs || DEFAULT_STAGGER_DELAY_MS);
const result = await Promise.race([resultPromise, timeoutPromise]);
const providerMetadata = {
providerIndex: pIndex,
rpcUrl: provider.getBaseUrl(),
method: `${method}(${JSON.stringify(params)})`,
chainId: this.network.chainId,
};
if (result.status === ProviderStatus.Success) {
return result.value;
}
else if (result.status === ProviderStatus.Timeout) {
this.logger.debug({ ...providerMetadata }, `Slow response from provider:`, isLastProvider ? '' : 'Triggering next provider.');
providerResultPromises.push(resultPromise);
pIndex += 1;
}
else if (result.status === ProviderStatus.Error) {
this.logger.debug({
error: result.error,
...providerMetadata,
}, `Error from provider.`, isLastProvider ? '' : 'Triggering next provider.');
providerResultErrors.push(result.error);
pIndex += 1;
}
else {
throw new Error(`Unexpected result from provider: ${JSON.stringify(providerMetadata)}`);
}
// 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([result, ...providerResultErrors], `All providers timed out on chain ${this._network.name} for method ${method}`);
}
else if (result.status === ProviderStatus.Error) {
this.throwCombinedProviderErrors([result.error, ...providerResultErrors], `All providers failed on chain ${this._network.name} for method ${method} and params ${JSON.stringify(params, null, 2)}`);
}
else {
throw new Error('Unexpected result from provider');
}
// All providers have already failed, all hope is lost
}
else {
this.throwCombinedProviderErrors(providerResultErrors, `All providers failed on chain ${this._network.name} for method ${method} and params ${JSON.stringify(params, null, 2)}`);
}
}
}
// Warp for additional logging and error handling
async wrapProviderPerform(provider, pIndex, method, params, reqId) {
try {
if (this.options?.debug)
this.logger.debug(`Provider #${pIndex} performing method ${method} for reqId ${reqId}`);
const result = await provider.perform(method, params, reqId);
return { status: ProviderStatus.Success, value: result };
}
catch (error) {
if (this.options?.debug)
this.logger.error(`Error performing ${method} on provider #${pIndex} for reqId ${reqId}`, error);
return { status: ProviderStatus.Error, error };
}
}
// Returns the first success from a list a promises, or an error if all fail
async waitForProviderSuccess(resultPromises) {
const combinedErrors = [];
const resolvedPromises = new Set();
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'),
};
}
throwCombinedProviderErrors(errors, fallbackMsg) {
this.logger.debug(fallbackMsg);
if (errors.length === 0)
throw new Error(fallbackMsg);
const rpcServerError = errors.find((e) => RPC_SERVER_ERRORS.includes(e.code));
const timedOutError = errors.find((e) => e.status === ProviderStatus.Timeout);
const rpcBlockchainError = errors.find((e) => RPC_BLOCKCHAIN_ERRORS.includes(e.code));
if (rpcServerError) {
throw Error(rpcServerError.error?.message ?? // Server errors sometimes will not have an error.message
getSmartProviderErrorMessage(rpcServerError.code), { cause: rpcServerError });
}
else if (timedOutError) {
throw Error(getSmartProviderErrorMessage(ProviderStatus.Timeout), {
cause: timedOutError,
});
}
else if (rpcBlockchainError) {
throw Error(rpcBlockchainError.reason ?? rpcBlockchainError.code, {
cause: rpcBlockchainError,
});
}
else {
this.logger.error('Unhandled error case in combined provider error handler');
throw Error(fallbackMsg);
}
}
}
function chainMetadataToProviderNetwork(chainMetadata) {
return {
name: chainMetadata.name,
chainId: chainMetadata.chainId,
// @ts-ignore add ensAddress to ChainMetadata
ensAddress: chainMetadata.ensAddress,
};
}
function timeoutResult(staggerDelay, multiplier = 1) {
return new Promise((resolve) => setTimeout(() => resolve({
status: ProviderStatus.Timeout,
}), staggerDelay * multiplier));
}
//# sourceMappingURL=SmartProvider.js.map