UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

483 lines 21.5 kB
import { providers, } from 'ethers'; import { Provider as ZKSyncProvider, } from 'zksync-ethers'; import { ProtocolType, addBufferToGasLimit, assert, pick, rootLogger, timeout, } from '@hyperlane-xyz/utils'; import { testChainMetadata, testChains } from '../consts/testChains.js'; import { ChainMetadataManager } from '../metadata/ChainMetadataManager.js'; import { ChainTechnicalStack, } from '../metadata/chainMetadataTypes.js'; import { ZKSyncDeployer } from '../zksync/ZKSyncDeployer.js'; import { defaultProviderBuilder } from './builders/ethersV5.js'; import { defaultTronEthersProviderBuilder } from './builders/tron.js'; import { defaultZKProviderBuilder } from './builders/zksync.js'; const DEFAULT_CONFIRMATION_TIMEOUT_MS = 300_000; const MIN_CONFIRMATION_TIMEOUT_MS = 30_000; /** * A utility class to create and manage providers and signers for multiple chains * @typeParam MetaExt - Extra metadata fields for chains (such as contract addresses) */ export class MultiProvider extends ChainMetadataManager { options; providers; providerBuilder; signers; useSharedSigner = false; // A single signer to be used for all chains logger; /** * Create a new MultiProvider with the given chainMetadata, * or the SDK's default metadata if not provided */ constructor(chainMetadata, options = {}) { super(chainMetadata, options); this.options = options; this.logger = options?.logger || rootLogger.child({ module: 'MultiProvider', }); this.providers = options?.providers || {}; this.providerBuilder = options?.providerBuilder || defaultProviderBuilder; this.signers = options?.signers || {}; } addChain(metadata) { super.addChain(metadata); if (this.useSharedSigner) { const signers = Object.values(this.signers); if (signers.length > 0) { this.setSharedSigner(signers[0]); } } } extendChainMetadata(additionalMetadata) { const newMetadata = super.extendChainMetadata(additionalMetadata).metadata; return new MultiProvider(newMetadata, this.options); } /** * Get an Ethers provider for a given chain name or domain id */ tryGetProvider(chainNameOrId) { const metadata = this.tryGetChainMetadata(chainNameOrId); if (!metadata) return null; const { name, chainId, rpcUrls, protocol, technicalStack } = metadata; if (this.providers[name]) return this.providers[name]; if (testChains.includes(name)) { if (technicalStack === ChainTechnicalStack.ZkSync) { this.providers[name] = new ZKSyncProvider('http://127.0.0.1:8011', 260); } else { this.providers[name] = new providers.JsonRpcProvider('http://127.0.0.1:8545', 31337); } } else if (rpcUrls.length) { if (technicalStack === ChainTechnicalStack.ZkSync) { this.providers[name] = defaultZKProviderBuilder(rpcUrls, chainId); } else if (protocol === ProtocolType.Tron) { this.providers[name] = defaultTronEthersProviderBuilder(rpcUrls, chainId); } else { this.providers[name] = this.providerBuilder(rpcUrls, chainId); } } else { return null; } return this.providers[name]; } /** * Get an Ethers provider for a given chain name or domain id * @throws if chain's metadata has not been set */ getProvider(chainNameOrId) { const provider = this.tryGetProvider(chainNameOrId); if (!provider) throw new Error(`No chain metadata set for ${chainNameOrId}`); return provider; } /** * Sets an Ethers provider for a given chain name or domain id * @throws if chain's metadata has not been set */ setProvider(chainNameOrId, provider) { const chainName = this.getChainName(chainNameOrId); this.providers[chainName] = provider; const signer = this.signers[chainName]; if (signer && signer.provider && !this.useSharedSigner) { try { this.setSigner(chainName, signer.connect(provider)); } catch (e) { // JsonRpcSigner throws UNSUPPORTED_OPERATION for .connect(); // use a type guard instead of `as` cast to safely access .code const code = typeof e === 'object' && e !== null && 'code' in e ? String(e.code) : undefined; if (code !== 'UNSUPPORTED_OPERATION') { throw e; } } } return provider; } /** * Sets Ethers providers for a set of chains * @throws if chain's metadata has not been set */ setProviders(providers) { for (const chain of Object.keys(providers)) { this.setProvider(chain, providers[chain]); } } /** * Get an Ethers signer for a given chain name or domain id * If signer is not yet connected, it will be connected */ tryGetSigner(chainNameOrId) { const chainName = this.tryGetChainName(chainNameOrId); if (!chainName) return null; const signer = this.signers[chainName]; if (!signer) return null; if (signer.provider) return signer; // Auto-connect the signer for convenience const provider = this.tryGetProvider(chainName); if (!provider) return signer; const connected = signer.connect(provider); // Only cache when not using a shared signer. In shared-signer mode, // caching pins the signer to this provider; setProvider() skips // reconnection when useSharedSigner is true, so the cached signer // would go stale after a provider swap. if (!this.useSharedSigner) { this.signers[chainName] = connected; } return connected; } /** * Get an Ethers signer for a given chain name or domain id * If signer is not yet connected, it will be connected * @throws if chain's metadata or signer has not been set */ getSigner(chainNameOrId) { const signer = this.tryGetSigner(chainNameOrId); if (!signer) throw new Error(`No chain signer set for ${chainNameOrId}`); return signer; } /** * Get an Ethers signer for a given chain name or domain id * @throws if chain's metadata or signer has not been set */ async getSignerAddress(chainNameOrId) { const signer = this.getSigner(chainNameOrId); const address = await signer.getAddress(); return address; } /** * Sets an Ethers Signer for a given chain name or domain id * @throws if chain's metadata has not been set or shared signer has already been set */ setSigner(chainNameOrId, signer) { if (this.useSharedSigner) { throw new Error('MultiProvider already set to use a shared signer'); } const chainName = this.getChainName(chainNameOrId); this.signers[chainName] = signer; if (signer.provider && !this.providers[chainName]) { this.providers[chainName] = signer.provider; } return signer; } /** * Sets Ethers Signers for a set of chains * @throws if chain's metadata has not been set or shared signer has already been set */ setSigners(signers) { if (this.useSharedSigner) { throw new Error('MultiProvider already set to use a shared signer'); } for (const chain of Object.keys(signers)) { const chainName = this.getChainName(chain); this.signers[chainName] = signers[chain]; } } /** * Gets the Signer if it's been set, otherwise the provider */ tryGetSignerOrProvider(chainNameOrId) { return (this.tryGetSigner(chainNameOrId) || this.tryGetProvider(chainNameOrId)); } /** * Gets the Signer if it's been set, otherwise the provider * @throws if chain metadata has not been set */ getSignerOrProvider(chainNameOrId) { return this.tryGetSigner(chainNameOrId) || this.getProvider(chainNameOrId); } /** * Sets Ethers Signers to be used for all chains * Any subsequent calls to getSigner will return given signer * Setting sharedSigner to null clears all signers */ setSharedSigner(sharedSigner) { if (!sharedSigner) { this.useSharedSigner = false; this.signers = {}; return null; } this.useSharedSigner = true; for (const chain of this.getKnownChainNames()) { this.signers[chain] = sharedSigner; } return sharedSigner; } /** * Create a new MultiProvider from the intersection * of current's chains and the provided chain list */ intersect(chains, throwIfNotSubset = false) { const { intersection, result } = super.intersect(chains, throwIfNotSubset); const multiProvider = new MultiProvider(result.metadata, { ...this.options, providers: pick(this.providers, intersection), signers: pick(this.signers, intersection), }); return { intersection, result: multiProvider }; } /** * Get a block explorer URL for given chain's address */ async tryGetExplorerAddressUrl(chainNameOrId, address) { if (address) return super.tryGetExplorerAddressUrl(chainNameOrId, address); const signer = this.tryGetSigner(chainNameOrId); if (signer) { const signerAddr = await signer.getAddress(); return super.tryGetExplorerAddressUrl(chainNameOrId, signerAddr); } return null; } /** * Get the latest block range for a given chain's RPC provider */ async getLatestBlockRange(chainNameOrId, rangeSize = this.getMaxBlockRange(chainNameOrId)) { const toBlock = await this.getProvider(chainNameOrId).getBlock('latest'); assert(toBlock, `Unable to fetch latest block for ${chainNameOrId}`); const fromBlock = Math.max(toBlock.number - rangeSize, 0); return { fromBlock, toBlock: toBlock.number }; } /** * Get the transaction overrides for a given chain name or domain id * @throws if chain's metadata has not been set */ getTransactionOverrides(chainNameOrId) { return this.getChainMetadata(chainNameOrId)?.transactionOverrides ?? {}; } /** * Wait for deploy tx to be confirmed * @throws if chain's metadata or signer has not been set or tx fails */ async handleDeploy(chainNameOrId, factory, params, artifact) { const overrides = this.getTransactionOverrides(chainNameOrId); const signer = this.getSigner(chainNameOrId); const metadata = this.getChainMetadata(chainNameOrId); const { protocol, technicalStack } = metadata; let contract; let estimatedGas; // estimate gas for deploy // deploy with buffer on gas limit if (technicalStack === ChainTechnicalStack.ZkSync) { if (!artifact) throw new Error(`No ZkSync contract artifact provided!`); const deployer = new ZKSyncDeployer(signer); estimatedGas = await deployer.estimateDeployGas(artifact, params); contract = await deployer.deploy(artifact, params, { gasLimit: addBufferToGasLimit(estimatedGas), ...overrides, }); // no need to `handleTx` for zkSync as the zksync deployer itself // will wait for the deploy tx to be confirmed before returning } else { const resolved = protocol === ProtocolType.Tron ? await this.resolveTronFactory(factory) : factory; const contractFactory = resolved.connect(signer); const deployTx = contractFactory.getDeployTransaction(...params); estimatedGas = await signer.estimateGas(deployTx); contract = await contractFactory.deploy(...params, { gasLimit: addBufferToGasLimit(estimatedGas), ...overrides, }); // manually wait for deploy tx to be confirmed assert(contract.deployTransaction, 'Deploy transaction missing'); await this.handleTx(chainNameOrId, contract.deployTransaction); } this.logger.trace(`Contract deployed at ${contract.address} on ${chainNameOrId}:`, { transaction: contract.deployTransaction }); // return deployed contract return contract; } /** * Resolve a core typechain factory to its Tron-compiled equivalent * wrapped with TronContractFactory for deployment. * * @hyperlane-xyz/tron-sdk exports typechain factories with class names identical to * @hyperlane-xyz/core (e.g. Mailbox__factory), generated from the same Solidity source. * They share the same ABIs and deploy signatures, differing only in TVM bytecode. * * Looks up the tron factory by factory.constructor.name and wraps it * with TronContractFactory to handle Tron's deployment flow. * @throws if no matching Tron factory is found */ async resolveTronFactory(factory) { const TronSdk = await import('@hyperlane-xyz/tron-sdk'); const TronFactory = TronSdk[factory.constructor.name]; if (!TronFactory) { throw new Error(`No Tron-compiled factory found for ${factory.constructor.name}`); } return new TronSdk.TronContractFactory(new TronFactory()); } /** * Wait for given tx to be confirmed * @param options - Optional configuration including waitConfirmations and timeoutMs * @throws if chain's metadata or signer has not been set, tx fails, block tag unsupported, or timeout exceeded */ async handleTx(chainNameOrId, tx, options) { const response = await tx; const txUrl = this.tryGetExplorerTxUrl(chainNameOrId, response); const metadata = this.getChainMetadata(chainNameOrId); // Use provided waitConfirmations, or fall back to chain metadata confirmations const confirmations = options?.waitConfirmations ?? metadata.blocks?.confirmations ?? 1; const estimateBlockTime = metadata.blocks?.estimateBlockTime; const minTimeout = this.options.minConfirmationTimeoutMs ?? MIN_CONFIRMATION_TIMEOUT_MS; const dynamicTimeout = typeof confirmations === 'number' && estimateBlockTime ? Math.max(confirmations * estimateBlockTime * 1000 * 2, minTimeout) : DEFAULT_CONFIRMATION_TIMEOUT_MS; const timeoutMs = options?.timeoutMs ?? dynamicTimeout; // Handle string block tags (e.g., "finalized", "safe") if (typeof confirmations === 'string') { this.logger.info(`Pending ${txUrl || response.hash} (waiting for ${confirmations} block)`); return this.waitForBlockTag(chainNameOrId, response, confirmations, timeoutMs); } // Handle numeric confirmations this.logger.info(`Pending ${txUrl || response.hash} (waiting ${confirmations} blocks for confirmation)`); const receipt = await timeout(response.wait(confirmations), timeoutMs, `Timeout (${timeoutMs}ms) waiting for ${confirmations} block confirmations for tx ${response.hash}`); // ethers v5 can return null for wait(0) if tx is still pending. if (receipt) return receipt; this.logger.info(`Pending ${txUrl || response.hash} (wait(0) returned pending, waiting for initial inclusion)`); const inclusionReceipt = await timeout(response.wait(1), timeoutMs, `Timeout (${timeoutMs}ms) waiting for initial inclusion for tx ${response.hash}`); assert(inclusionReceipt, `Transaction ${response.hash} was not included`); return inclusionReceipt; } /** * Wait for a transaction to be included in a block with the given tag (e.g., "finalized", "safe"). * Polls until the tagged block number >= transaction block number. * @param timeoutMs - Timeout in ms (default: 300000 = 5 min) * @throws if block tag is unsupported by the RPC provider or timeout exceeded * @internal - Prefer using handleTx with waitConfirmations parameter. */ async waitForBlockTag(chainNameOrId, response, blockTag, timeoutMs = DEFAULT_CONFIRMATION_TIMEOUT_MS) { const provider = this.getProvider(chainNameOrId); const receipt = await response.wait(1); // Wait for initial inclusion assert(receipt, `Transaction ${response.hash} was not included`); assert(typeof receipt.blockNumber === 'number', `Receipt missing block number for tx ${response.hash}`); const txBlock = receipt.blockNumber; // Check if block tag is supported on first call const initialTaggedBlock = await provider.getBlock(blockTag); if (initialTaggedBlock === null) { throw new Error(`Block tag "${blockTag}" not supported by RPC provider for chain ${chainNameOrId}`); } // Check if already confirmed if (initialTaggedBlock.number >= txBlock) { this.logger.info(`Transaction ${response.hash} confirmed at ${blockTag} block ${initialTaggedBlock.number}`); // Re-fetch receipt to get canonical block info after potential reorgs const finalReceipt = await provider.getTransactionReceipt(response.hash); if (!finalReceipt) { throw new Error(`Transaction ${response.hash} not found after ${blockTag} confirmation - may have been reorged out`); } return finalReceipt; } const POLL_INTERVAL_MS = 2000; const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); const taggedBlock = await provider.getBlock(blockTag); if (taggedBlock && taggedBlock.number >= txBlock) { this.logger.info(`Transaction ${response.hash} confirmed at ${blockTag} block ${taggedBlock.number}`); // Re-fetch receipt to get canonical block info after potential reorgs const finalReceipt = await provider.getTransactionReceipt(response.hash); if (!finalReceipt) { throw new Error(`Transaction ${response.hash} not found after ${blockTag} confirmation - may have been reorged out`); } return finalReceipt; } } throw new Error(`Timeout (${timeoutMs}ms) waiting for ${blockTag} block for tx ${response.hash}`); } /** * Populate a transaction's fields using signer address and overrides * @throws if chain's metadata has not been set or tx fails */ async prepareTx(chainNameOrId, tx, from) { const txFrom = from ?? (await this.getSignerAddress(chainNameOrId)); const overrides = this.getTransactionOverrides(chainNameOrId); return { ...tx, from: txFrom, ...overrides, }; } /** * Estimate gas for given tx * @throws if chain's metadata has not been set or tx fails */ async estimateGas(chainNameOrId, tx, from) { const txReq = { ...(await this.prepareTx(chainNameOrId, tx, from)), // Reset any tx request params that may have an unintended effect on gas estimation gasLimit: undefined, gasPrice: undefined, maxPriorityFeePerGas: undefined, maxFeePerGas: undefined, }; const provider = this.getProvider(chainNameOrId); return provider.estimateGas(txReq); } /** * Send a transaction and wait for confirmation * @param options - Optional configuration including waitConfirmations * @throws if chain's metadata or signer has not been set or tx fails */ async sendTransaction(chainNameOrId, txProm, options) { const { annotation, ...tx } = await txProm; if (annotation) { this.logger.info(annotation); } const txReq = await this.prepareTx(chainNameOrId, tx); const signer = this.getSigner(chainNameOrId); const response = await signer.sendTransaction(txReq); this.logger.info(`Sent tx ${response.hash}`); return this.handleTx(chainNameOrId, response, options); } /** * Creates a MultiProvider using the given signer for all test networks */ static createTestMultiProvider(params = {}, chains = testChains) { const { signer, provider } = params; const mp = new MultiProvider(testChainMetadata); if (signer) { mp.setSharedSigner(signer); } const _provider = provider || signer?.provider; if (_provider) { const providerMap = {}; chains.forEach((t) => (providerMap[t] = _provider)); mp.setProviders(providerMap); } return mp; } } //# sourceMappingURL=MultiProvider.js.map