UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

327 lines 15.2 kB
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