Replace Fallback/Retry providers with SmartProvider (#3032)
### Description - Port `HyperlaneSmartProvider` from explorer - Use as default ethers v5 provider - Includes `HyperlaneJsonRpcProvider` and `HyperlaneEtherscanProvider` for more granular control ### Related issues Fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/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 CIpull/3058/head
parent
79bd348585
commit
b832e57aef
@ -0,0 +1,5 @@ |
||||
--- |
||||
'@hyperlane-xyz/sdk': minor |
||||
--- |
||||
|
||||
Replace Fallback and Retry Providers with new SmartProvider with more effective fallback/retry logic |
@ -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" |
@ -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<any>): Promise<any> { |
||||
return retryAsync( |
||||
() => super.send(method, params), |
||||
this.retryOptions.maxRequests, |
||||
this.retryOptions.baseRetryMs, |
||||
); |
||||
} |
||||
} |
@ -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); |
||||
} |
@ -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<any> { |
||||
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<any> { |
||||
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<providers.Log> = []; |
||||
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<Array<providers.Log>>, |
||||
); |
||||
const results = await Promise.all(resultPromises); |
||||
combinedResults = [...combinedResults, ...results.flat()]; |
||||
} |
||||
|
||||
return combinedResults; |
||||
} |
||||
|
||||
getBaseUrl(): string { |
||||
return this.connection.url; |
||||
} |
||||
} |
@ -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)); |
||||
} |
@ -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; |
||||
}); |
||||
}); |
@ -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; |
||||
}); |
||||
} |
||||
}); |
@ -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<ProviderMethod>(); |
||||
|
||||
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<providers.Network> { |
||||
// 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<any> { |
||||
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<boolean> { |
||||
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<HyperlaneEtherscanProvider | HyperlaneJsonRpcProvider>, |
||||
reqId: number, |
||||
): Promise<any> { |
||||
let pIndex = 0; |
||||
const providerResultPromises: Promise<ProviderPerformResult>[] = []; |
||||
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<ProviderPerformResult> { |
||||
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<ProviderPerformResult>[], |
||||
): Promise<ProviderPerformResult> { |
||||
const combinedErrors: unknown[] = []; |
||||
const resolvedPromises = new Set<Promise<ProviderPerformResult>>(); |
||||
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<ProviderTimeoutResult>((resolve) => |
||||
setTimeout( |
||||
() => |
||||
resolve({ |
||||
status: ProviderStatus.Timeout, |
||||
}), |
||||
staggerDelay * multiplier, |
||||
), |
||||
); |
||||
} |
@ -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<ChainMetadata, 'rpcUrls'> { |
||||
rpcUrls: Array<RpcConfigWithConnectionInfo>; |
||||
} |
||||
|
||||
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; |
||||
} |
Loading…
Reference in new issue