diff --git a/.changeset/tiny-spiders-argue.md b/.changeset/tiny-spiders-argue.md new file mode 100644 index 000000000..4662aabfb --- /dev/null +++ b/.changeset/tiny-spiders-argue.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/sdk': minor +--- + +Replace Fallback and Retry Providers with new SmartProvider with more effective fallback/retry logic diff --git a/typescript/infra/src/config/chain.ts b/typescript/infra/src/config/chain.ts index 9d8197fc0..9b6ff567d 100644 --- a/typescript/infra/src/config/chain.ts +++ b/typescript/infra/src/config/chain.ts @@ -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 { + 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}`); } } diff --git a/typescript/sdk/package.json b/typescript/sdk/package.json index 8e8b0df60..78cc14ce7 100644 --- a/typescript/sdk/package.json +++ b/typescript/sdk/package.json @@ -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": { diff --git a/typescript/sdk/scripts/foundry-test.sh b/typescript/sdk/scripts/foundry-test.sh new file mode 100755 index 000000000..2eef6dfb6 --- /dev/null +++ b/typescript/sdk/scripts/foundry-test.sh @@ -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" \ No newline at end of file diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index 17692072e..06195db0d 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -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, diff --git a/typescript/sdk/src/providers/MultiProvider.ts b/typescript/sdk/src/providers/MultiProvider.ts index bef14b999..7973cc312 100644 --- a/typescript/sdk/src/providers/MultiProvider.ts +++ b/typescript/sdk/src/providers/MultiProvider.ts @@ -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 extends ChainMetadataManager { 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; } diff --git a/typescript/sdk/src/providers/RetryProvider.ts b/typescript/sdk/src/providers/RetryProvider.ts deleted file mode 100644 index 3f7950f1c..000000000 --- a/typescript/sdk/src/providers/RetryProvider.ts +++ /dev/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): Promise { - return retryAsync( - () => super.send(method, params), - this.retryOptions.maxRequests, - this.retryOptions.baseRetryMs, - ); - } -} diff --git a/typescript/sdk/src/providers/SmartProvider/HyperlaneEtherscanProvider.ts b/typescript/sdk/src/providers/SmartProvider/HyperlaneEtherscanProvider.ts new file mode 100644 index 000000000..7177cf229 --- /dev/null +++ b/typescript/sdk/src/providers/SmartProvider/HyperlaneEtherscanProvider.ts @@ -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 = {}; +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 { + 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, + post?: boolean, + ): Promise { + 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 { + 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 { + const args: Record = { 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); +} diff --git a/typescript/sdk/src/providers/SmartProvider/HyperlaneJsonRpcProvider.ts b/typescript/sdk/src/providers/SmartProvider/HyperlaneJsonRpcProvider.ts new file mode 100644 index 000000000..15220ef3a --- /dev/null +++ b/typescript/sdk/src/providers/SmartProvider/HyperlaneJsonRpcProvider.ts @@ -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 { + 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 { + 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 = []; + 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>, + ); + const results = await Promise.all(resultPromises); + combinedResults = [...combinedResults, ...results.flat()]; + } + + return combinedResults; + } + + getBaseUrl(): string { + return this.connection.url; + } +} diff --git a/typescript/sdk/src/providers/SmartProvider/ProviderMethods.ts b/typescript/sdk/src/providers/SmartProvider/ProviderMethods.ts new file mode 100644 index 000000000..2a36c6599 --- /dev/null +++ b/typescript/sdk/src/providers/SmartProvider/ProviderMethods.ts @@ -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)); +} diff --git a/typescript/sdk/src/providers/SmartProvider/SmartProvider.foundry-test.ts b/typescript/sdk/src/providers/SmartProvider/SmartProvider.foundry-test.ts new file mode 100644 index 000000000..8b55e33fd --- /dev/null +++ b/typescript/sdk/src/providers/SmartProvider/SmartProvider.foundry-test.ts @@ -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; + }); +}); diff --git a/typescript/sdk/src/providers/SmartProvider/SmartProvider.test.ts b/typescript/sdk/src/providers/SmartProvider/SmartProvider.test.ts new file mode 100644 index 000000000..0deddd6a3 --- /dev/null +++ b/typescript/sdk/src/providers/SmartProvider/SmartProvider.test.ts @@ -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; + }); + } +}); diff --git a/typescript/sdk/src/providers/SmartProvider/SmartProvider.ts b/typescript/sdk/src/providers/SmartProvider/SmartProvider.ts new file mode 100644 index 000000000..49dbda1c5 --- /dev/null +++ b/typescript/sdk/src/providers/SmartProvider/SmartProvider.ts @@ -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(); + + 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 { + // 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 { + 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 { + 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, + reqId: number, + ): Promise { + let pIndex = 0; + const providerResultPromises: Promise[] = []; + 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 { + 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[], + ): Promise { + const combinedErrors: unknown[] = []; + 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'), + }; + } + + 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((resolve) => + setTimeout( + () => + resolve({ + status: ProviderStatus.Timeout, + }), + staggerDelay * multiplier, + ), + ); +} diff --git a/typescript/sdk/src/providers/SmartProvider/types.ts b/typescript/sdk/src/providers/SmartProvider/types.ts new file mode 100644 index 000000000..99ab30623 --- /dev/null +++ b/typescript/sdk/src/providers/SmartProvider/types.ts @@ -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 { + rpcUrls: Array; +} + +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; +} diff --git a/typescript/sdk/src/providers/providerBuilders.ts b/typescript/sdk/src/providers/providerBuilders.ts index 5cab49453..b678cf53e 100644 --- a/typescript/sdk/src/providers/providerBuilders.ts +++ b/typescript/sdk/src/providers/providerBuilders.ts @@ -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

= ( rpcUrls: ChainMetadata['rpcUrls'], network: number | string, - retryOverride?: RetryProviderOptions, + retryOverride?: ProviderRetryOptions, ) => P; export type TypedProviderBuilderFn = ProviderBuilderFn; -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, diff --git a/typescript/utils/index.ts b/typescript/utils/index.ts index 08abee53a..4730a417f 100644 --- a/typescript/utils/index.ts +++ b/typescript/utils/index.ts @@ -48,6 +48,7 @@ export { export { chunk, exclude } from './src/arrays'; export { pollAsync, + raceWithContext, retryAsync, runWithTimeout, sleep, diff --git a/typescript/utils/src/async.ts b/typescript/utils/src/async.ts index f57514d68..2665f816b 100644 --- a/typescript/utils/src/async.ts +++ b/typescript/utils/src/async.ts @@ -102,3 +102,17 @@ export async function pollAsync( } 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( + promises: Array>, +): Promise<{ resolved: T; promise: Promise; index: number }> { + const promisesWithContext = promises.map((p, i) => + p.then((resolved) => ({ resolved, promise: p, index: i })), + ); + return Promise.race(promisesWithContext); +}