@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
381 lines • 17.8 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.SERVER_ERROR,
EthersError.TIMEOUT,
EthersError.UNKNOWN_ERROR,
];
const RPC_BLOCKCHAIN_ERRORS = [
EthersError.CALL_EXCEPTION,
EthersError.INSUFFICIENT_FUNDS,
EthersError.NETWORK_ERROR,
EthersError.NONCE_EXPIRED,
EthersError.NOT_IMPLEMENTED,
EthersError.REPLACEMENT_UNDERPRICED,
EthersError.TRANSACTION_REPLACED,
EthersError.UNPREDICTABLE_GAS_LIMIT,
EthersError.UNSUPPORTED_OPERATION,
];
const DEFAULT_MAX_RETRIES = 1;
const DEFAULT_BASE_RETRY_DELAY_MS = 250; // 0.25 seconds
const DEFAULT_STAGGER_DELAY_MS = 1000; // 1 seconds
const DEFAULT_PHASE2_WAIT_MULTIPLIER = 20;
export class BlockchainError extends Error {
isRecoverable = false;
constructor(message, options) {
super(message, options);
}
static {
this.prototype.name = this.name;
}
}
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 = 3_000) {
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 has two phases:
* 1. Sequentially triggers providers until success or blockchain error (permanent failure)
* 2. Waits for any remaining pending provider promises to complete
* 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 = [];
// Phase 1: Trigger providers sequentially until success or blockchain error
providerLoop: while (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,
};
switch (result.status) {
case ProviderStatus.Success:
return result.value;
case ProviderStatus.Timeout:
this.logger.debug({ ...providerMetadata }, `Slow response from provider:`, isLastProvider ? '' : 'Triggering next provider.');
providerResultPromises.push(resultPromise);
pIndex += 1;
break;
case ProviderStatus.Error: {
providerResultErrors.push(result.error);
// If this is a blockchain error, stop trying additional providers as it's a permanent failure
// Exception: CALL_EXCEPTION without revert data is likely an RPC issue, not a real revert
// Note: ethers sets data to "0x" when there's no actual revert data
const errorCode = result.error?.code;
const revertData = result.error?.data;
const hasRevertData = !!revertData && revertData !== '0x';
const isCallExceptionWithoutData = errorCode === EthersError.CALL_EXCEPTION && !hasRevertData;
const isPermanentBlockchainError = RPC_BLOCKCHAIN_ERRORS.includes(errorCode) &&
!isCallExceptionWithoutData;
if (isPermanentBlockchainError) {
this.logger.debug({ ...providerMetadata }, `${errorCode} detected - stopping provider fallback as this is a permanent failure`);
break providerLoop;
}
if (isCallExceptionWithoutData) {
this.logger.debug({ ...providerMetadata }, `${errorCode} without revert data detected - treating as transient RPC error, will retry`);
}
this.logger.debug({
error: result.error,
...providerMetadata,
}, `Error from provider.`, isLastProvider ? '' : 'Triggering next provider.');
pIndex += 1;
break;
}
default:
throw new Error(`Unexpected result from provider: ${JSON.stringify(providerMetadata)}`);
}
}
// Phase 2: All providers already triggered, wait for one to complete or all to fail/timeout
// If no providers are left, all have already failed
if (providerResultPromises.length === 0) {
const CombinedError = this.getCombinedProviderError(providerResultErrors, `All providers failed on chain ${this.network.name} for method ${method} and params ${JSON.stringify(params, null, 2)}`);
throw new CombinedError();
}
// Wait for at least one provider to succeed or all to fail/timeout
const timeoutPromise = timeoutResult(this.options?.fallbackStaggerMs || DEFAULT_STAGGER_DELAY_MS, DEFAULT_PHASE2_WAIT_MULTIPLIER);
const resultPromise = this.waitForProviderSuccess(providerResultPromises);
const result = await Promise.race([resultPromise, timeoutPromise]);
switch (result.status) {
case ProviderStatus.Success:
return result.value;
case ProviderStatus.Timeout: {
const CombinedError = this.getCombinedProviderError([result, ...providerResultErrors], `All providers timed out on chain ${this.network.name} for method ${method}`);
throw new CombinedError();
}
case ProviderStatus.Error: {
const CombinedError = this.getCombinedProviderError([result.error, ...providerResultErrors], `All providers failed on chain ${this.network.name} for method ${method} and params ${JSON.stringify(params, null, 2)}`);
throw new CombinedError();
}
default:
throw new Error('Unexpected result from provider');
}
}
// Wrap 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'),
};
}
getCombinedProviderError(errors, fallbackMsg) {
this.logger.debug(fallbackMsg);
if (errors.length === 0) {
return class extends Error {
constructor() {
super(fallbackMsg);
}
};
}
// Find blockchain errors, but exclude CALL_EXCEPTION without revert data (likely RPC issues)
// Note: ethers sets data to "0x" when there's no actual revert data
const rpcBlockchainError = errors.find((e) => RPC_BLOCKCHAIN_ERRORS.includes(e.code) &&
!(e.code === EthersError.CALL_EXCEPTION &&
(!e.data || e.data === '0x')));
const rpcServerError = errors.find((e) => RPC_SERVER_ERRORS.includes(e.code));
const timedOutError = errors.find((e) => e.status === ProviderStatus.Timeout);
if (rpcBlockchainError) {
// All blockchain errors are non-retryable and take priority
return class extends BlockchainError {
constructor() {
super(rpcBlockchainError.reason ?? rpcBlockchainError.code, {
cause: rpcBlockchainError,
});
}
};
}
else if (rpcServerError) {
return class extends Error {
constructor() {
super(rpcServerError.error?.message ?? // Server errors sometimes will not have an error.message
getSmartProviderErrorMessage(rpcServerError.code), { cause: rpcServerError });
}
};
}
else if (timedOutError) {
return class extends Error {
constructor() {
super(fallbackMsg, {
cause: timedOutError,
});
}
};
}
else {
this.logger.error('Unhandled error case in combined provider error handler');
return class extends Error {
constructor() {
super(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