UNPKG

@hyperlane-xyz/cli

Version:

A command-line utility for common Hyperlane operations

517 lines 23.9 kB
import { confirm } from '@inquirer/prompts'; import { groupBy } from 'lodash-es'; import { stringify as yamlStringify } from 'yaml'; import { ProxyAdmin__factory } from '@hyperlane-xyz/core'; import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js'; import { CCIPContractCache, ChainSubmissionStrategySchema, ContractVerifier, EvmERC20WarpModule, EvmHookModule, EvmIsmModule, ExplorerLicenseType, HypERC20Deployer, HypERC721Deployer, HyperlaneProxyFactoryDeployer, IsmType, TOKEN_TYPE_TO_STANDARD, TxSubmitterType, WarpCoreConfigSchema, WarpRouteDeployConfigSchema, attachContractsMap, connectContractsMap, expandWarpDeployConfig, extractIsmAndHookFactoryAddresses, getRouterAddressesFromWarpCoreConfig, getTokenConnectionId, hypERC20factories, isCollateralTokenConfig, isTokenMetadata, isXERC20TokenConfig, splitWarpCoreAndExtendedConfigs, } from '@hyperlane-xyz/sdk'; import { ProtocolType, assert, objMap, promiseObjAll, retryAsync, } from '@hyperlane-xyz/utils'; import { readWarpRouteDeployConfig } from '../config/warp.js'; import { MINIMUM_WARP_DEPLOY_GAS } from '../consts.js'; import { requestAndSaveApiKeys } from '../context/context.js'; import { log, logBlue, logGray, logGreen, logTable } from '../logger.js'; import { getSubmitterBuilder } from '../submit/submit.js'; import { indentYamlOrJson, isFile, readYamlOrJson, runFileSelectionStep, writeYamlOrJson, } from '../utils/files.js'; import { completeDeploy, prepareDeploy, runPreflightChecksForChains, } from './utils.js'; export async function runWarpRouteDeploy({ context, warpRouteDeploymentConfigPath, }) { const { skipConfirmation, chainMetadata, registry } = context; if (!warpRouteDeploymentConfigPath || !isFile(warpRouteDeploymentConfigPath)) { if (skipConfirmation) throw new Error('Warp route deployment config required'); warpRouteDeploymentConfigPath = await runFileSelectionStep('./configs', 'Warp route deployment config', 'warp'); } else { log(`Using warp route deployment config at ${warpRouteDeploymentConfigPath}`); } const warpRouteConfig = await readWarpRouteDeployConfig(warpRouteDeploymentConfigPath, context); const chains = Object.keys(warpRouteConfig); let apiKeys = {}; if (!skipConfirmation) apiKeys = await requestAndSaveApiKeys(chains, chainMetadata, registry); const deploymentParams = { context, warpDeployConfig: warpRouteConfig, }; await runDeployPlanStep(deploymentParams); // Some of the below functions throw if passed non-EVM chains const ethereumChains = chains.filter((chain) => chainMetadata[chain].protocol === ProtocolType.Ethereum); await runPreflightChecksForChains({ context, chains: ethereumChains, minGas: MINIMUM_WARP_DEPLOY_GAS, }); const initialBalances = await prepareDeploy(context, null, ethereumChains); const deployedContracts = await executeDeploy(deploymentParams, apiKeys); const { warpCoreConfig, addWarpRouteOptions } = await getWarpCoreConfig(deploymentParams, deployedContracts); await writeDeploymentArtifacts(warpCoreConfig, context, addWarpRouteOptions); await completeDeploy(context, 'warp', initialBalances, null, ethereumChains); } async function runDeployPlanStep({ context, warpDeployConfig }) { const { skipConfirmation } = context; displayWarpDeployPlan(warpDeployConfig); if (skipConfirmation || context.isDryRun) return; const isConfirmed = await confirm({ message: 'Is this deployment plan correct?', }); if (!isConfirmed) throw new Error('Deployment cancelled'); } async function executeDeploy(params, apiKeys) { logBlue('🚀 All systems ready, captain! Beginning deployment...'); const { warpDeployConfig, context: { multiProvider, isDryRun, dryRunChain }, } = params; const deployer = warpDeployConfig.isNft ? new HypERC721Deployer(multiProvider) : new HypERC20Deployer(multiProvider); // TODO: replace with EvmERC20WarpModule const config = isDryRun && dryRunChain ? { [dryRunChain]: warpDeployConfig[dryRunChain] } : warpDeployConfig; const contractVerifier = new ContractVerifier(multiProvider, apiKeys, coreBuildArtifact, ExplorerLicenseType.MIT); const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider, contractVerifier); // For each chain in WarpRouteConfig, deploy each Ism Factory, if it's not in the registry // Then return a modified config with the ism and/or hook address as a string const modifiedConfig = await resolveWarpIsmAndHook(config, params.context, ismFactoryDeployer, contractVerifier); const deployedContracts = await deployer.deploy(modifiedConfig); logGreen('✅ Warp contract deployments complete'); return deployedContracts; } async function writeDeploymentArtifacts(warpCoreConfig, context, addWarpRouteOptions) { if (!context.isDryRun) { log('Writing deployment artifacts...'); await context.registry.addWarpRoute(warpCoreConfig, addWarpRouteOptions); } log(indentYamlOrJson(yamlStringify(warpCoreConfig, null, 2), 4)); } async function resolveWarpIsmAndHook(warpConfig, context, ismFactoryDeployer, contractVerifier) { return promiseObjAll(objMap(warpConfig, async (chain, config) => { const registryAddresses = await context.registry.getAddresses(); const ccipContractCache = new CCIPContractCache(registryAddresses); const chainAddresses = registryAddresses[chain]; if (!chainAddresses) { throw `Registry factory addresses not found for ${chain}.`; } config.interchainSecurityModule = await createWarpIsm({ ccipContractCache, chain, chainAddresses, context, contractVerifier, ismFactoryDeployer, warpConfig: config, }); // TODO write test config.hook = await createWarpHook({ ccipContractCache, chain, chainAddresses, context, contractVerifier, ismFactoryDeployer, warpConfig: config, }); return config; })); } /** * Deploys the Warp ISM for a given config * * @returns The deployed ism address */ async function createWarpIsm({ ccipContractCache, chain, chainAddresses, context, contractVerifier, warpConfig, }) { const { interchainSecurityModule } = warpConfig; if (!interchainSecurityModule || typeof interchainSecurityModule === 'string') { logGray(`Config Ism is ${!interchainSecurityModule ? 'empty' : interchainSecurityModule}, skipping deployment.`); return interchainSecurityModule; } logBlue(`Loading registry factory addresses for ${chain}...`); logGray(`Creating ${interchainSecurityModule.type} ISM for token on ${chain} chain...`); logGreen(`Finished creating ${interchainSecurityModule.type} ISM for token on ${chain} chain.`); const evmIsmModule = await EvmIsmModule.create({ chain, mailbox: chainAddresses.mailbox, multiProvider: context.multiProvider, proxyFactoryFactories: extractIsmAndHookFactoryAddresses(chainAddresses), config: interchainSecurityModule, ccipContractCache, contractVerifier, }); const { deployedIsm } = evmIsmModule.serialize(); return deployedIsm; } async function createWarpHook({ ccipContractCache, chain, chainAddresses, context, contractVerifier, warpConfig, }) { const { hook } = warpConfig; if (!hook || typeof hook === 'string') { logGray(`Config Hook is ${!hook ? 'empty' : hook}, skipping deployment.`); return hook; } logBlue(`Loading registry factory addresses for ${chain}...`); logGray(`Creating ${hook.type} Hook for token on ${chain} chain...`); // If config.proxyadmin.address exists, then use that. otherwise deploy a new proxyAdmin const proxyAdminAddress = warpConfig.proxyAdmin?.address ?? (await context.multiProvider.handleDeploy(chain, new ProxyAdmin__factory(), [])).address; const evmHookModule = await EvmHookModule.create({ chain, multiProvider: context.multiProvider, coreAddresses: { mailbox: chainAddresses.mailbox, proxyAdmin: proxyAdminAddress, }, config: hook, ccipContractCache, contractVerifier, proxyFactoryFactories: extractIsmAndHookFactoryAddresses(chainAddresses), }); logGreen(`Finished creating ${hook.type} Hook for token on ${chain} chain.`); const { deployedHook } = evmHookModule.serialize(); return deployedHook; } async function getWarpCoreConfig(params, contracts) { const warpCoreConfig = { tokens: [] }; // TODO: replace with warp read const tokenMetadata = await HypERC20Deployer.deriveTokenMetadata(params.context.multiProvider, params.warpDeployConfig); assert(tokenMetadata && isTokenMetadata(tokenMetadata), 'Missing required token metadata'); const { decimals, symbol, name } = tokenMetadata; assert(decimals, 'Missing decimals on token metadata'); generateTokenConfigs(warpCoreConfig, params.warpDeployConfig, contracts, symbol, name, decimals); fullyConnectTokens(warpCoreConfig); return { warpCoreConfig, addWarpRouteOptions: { symbol } }; } /** * Creates token configs. */ function generateTokenConfigs(warpCoreConfig, warpDeployConfig, contracts, symbol, name, decimals) { for (const [chainName, contract] of Object.entries(contracts)) { const config = warpDeployConfig[chainName]; const collateralAddressOrDenom = isCollateralTokenConfig(config) || isXERC20TokenConfig(config) ? config.token // gets set in the above deriveTokenMetadata() : undefined; warpCoreConfig.tokens.push({ chainName, standard: TOKEN_TYPE_TO_STANDARD[config.type], decimals, symbol: config.symbol || symbol, name, addressOrDenom: contract[warpDeployConfig[chainName].type] .address, collateralAddressOrDenom, }); } } /** * Adds connections between tokens. * * Assumes full interconnectivity between all tokens for now b.c. that's * what the deployers do by default. */ function fullyConnectTokens(warpCoreConfig) { for (const token1 of warpCoreConfig.tokens) { for (const token2 of warpCoreConfig.tokens) { if (token1.chainName === token2.chainName && token1.addressOrDenom === token2.addressOrDenom) continue; token1.connections ||= []; token1.connections.push({ token: getTokenConnectionId(ProtocolType.Ethereum, token2.chainName, token2.addressOrDenom), }); } } } export async function runWarpRouteApply(params) { const { warpDeployConfig, warpCoreConfig, context } = params; const { chainMetadata, skipConfirmation } = context; WarpRouteDeployConfigSchema.parse(warpDeployConfig); WarpCoreConfigSchema.parse(warpCoreConfig); const chains = Object.keys(warpDeployConfig); let apiKeys = {}; if (!skipConfirmation) apiKeys = await requestAndSaveApiKeys(chains, chainMetadata, context.registry); // Extend the warp route and get the updated configs const updatedWarpCoreConfig = await extendWarpRoute(params, apiKeys, warpCoreConfig); // Then create and submit update transactions const transactions = await updateExistingWarpRoute(params, apiKeys, warpDeployConfig, updatedWarpCoreConfig); if (transactions.length == 0) return logGreen(`Warp config is the same as target. No updates needed.`); await submitWarpApplyTransactions(params, groupBy(transactions, 'chainId')); } /** * Handles the deployment and configuration of new contracts for extending a Warp route. * This function performs several key steps: * 1. Derives metadata from existing contracts and applies it to new configurations * 2. Deploys new contracts using the derived configurations * 3. Merges existing and new router configurations * 4. Generates an updated Warp core configuration */ async function deployWarpExtensionContracts(params, apiKeys, existingConfigs, initialExtendedConfigs, warpCoreConfigByChain) { // Deploy new contracts with derived metadata const extendedConfigs = await deriveMetadataFromExisting(params.context.multiProvider, existingConfigs, initialExtendedConfigs); const newDeployedContracts = await executeDeploy({ context: params.context, warpDeployConfig: extendedConfigs, }, apiKeys); // Merge existing and new routers const mergedRouters = mergeAllRouters(params.context.multiProvider, existingConfigs, newDeployedContracts, warpCoreConfigByChain); // Get the updated core config const { warpCoreConfig: updatedWarpCoreConfig, addWarpRouteOptions } = await getWarpCoreConfig(params, mergedRouters); WarpCoreConfigSchema.parse(updatedWarpCoreConfig); return { newDeployedContracts, updatedWarpCoreConfig, addWarpRouteOptions, }; } /** * Extends an existing Warp route to include new chains. * This function manages the entire extension workflow: * 1. Divides the configuration into existing and new chain segments. * 2. Returns the current configuration if no new chains are added. * 3. Deploys and sets up new contracts for the additional chains. * 4. Refreshes the Warp core configuration with updated token details. * 5. Saves the revised artifacts to the registry. */ export async function extendWarpRoute(params, apiKeys, warpCoreConfig) { const { context, warpDeployConfig } = params; const warpCoreConfigByChain = Object.fromEntries(warpCoreConfig.tokens.map((token) => [token.chainName, token])); const warpCoreChains = Object.keys(warpCoreConfigByChain); // Split between the existing and additional config const [existingConfigs, initialExtendedConfigs] = splitWarpCoreAndExtendedConfigs(warpDeployConfig, warpCoreChains); const extendedChains = Object.keys(initialExtendedConfigs); if (extendedChains.length === 0) { return warpCoreConfig; } logBlue(`Extending Warp Route to ${extendedChains.join(', ')}`); // Deploy new contracts with derived metadata and merge with existing config const { updatedWarpCoreConfig, addWarpRouteOptions } = await deployWarpExtensionContracts(params, apiKeys, existingConfigs, initialExtendedConfigs, warpCoreConfigByChain); // Write the updated artifacts await writeDeploymentArtifacts(updatedWarpCoreConfig, context, addWarpRouteOptions); return updatedWarpCoreConfig; } // Updates Warp routes with new configurations. async function updateExistingWarpRoute(params, apiKeys, warpDeployConfig, warpCoreConfig) { logBlue('Updating deployed Warp Routes'); const { multiProvider, registry } = params.context; const registryAddresses = (await registry.getAddresses()); const ccipContractCache = new CCIPContractCache(registryAddresses); const contractVerifier = new ContractVerifier(multiProvider, apiKeys, coreBuildArtifact, ExplorerLicenseType.MIT); const transactions = []; // Get all deployed router addresses const deployedRoutersAddresses = getRouterAddressesFromWarpCoreConfig(warpCoreConfig); const expandedWarpDeployConfig = await expandWarpDeployConfig(multiProvider, warpDeployConfig, deployedRoutersAddresses); await promiseObjAll(objMap(expandedWarpDeployConfig, async (chain, config) => { await retryAsync(async () => { const deployedTokenRoute = deployedRoutersAddresses[chain]; assert(deployedTokenRoute, `Missing artifacts for ${chain}.`); const configWithMailbox = { ...config, mailbox: registryAddresses[chain].mailbox, }; const evmERC20WarpModule = new EvmERC20WarpModule(multiProvider, { config: configWithMailbox, chain, addresses: { deployedTokenRoute, ...extractIsmAndHookFactoryAddresses(registryAddresses[chain]), }, }, ccipContractCache, contractVerifier); transactions.push(...(await evmERC20WarpModule.update(configWithMailbox))); }); })); return transactions; } /** * Retrieves a chain submission strategy from the provided filepath. * @param submissionStrategyFilepath a filepath to the submission strategy file * @returns a formatted submission strategy */ export function readChainSubmissionStrategy(submissionStrategyFilepath) { const submissionStrategyFileContent = readYamlOrJson(submissionStrategyFilepath.trim()); return ChainSubmissionStrategySchema.parse(submissionStrategyFileContent); } /** * Derives token metadata from existing config and merges it with extended config. * @returns The merged Warp route deployment config with token metadata. */ async function deriveMetadataFromExisting(multiProvider, existingConfigs, extendedConfigs) { const existingTokenMetadata = await HypERC20Deployer.deriveTokenMetadata(multiProvider, existingConfigs); return objMap(extendedConfigs, (_chain, extendedConfig) => { return { ...existingTokenMetadata, ...extendedConfig, }; }); } /** * Merges existing router configs with newly deployed router contracts. */ function mergeAllRouters(multiProvider, existingConfigs, deployedContractsMap, warpCoreConfigByChain) { const existingContractAddresses = objMap(existingConfigs, (chain, config) => ({ [config.type]: warpCoreConfigByChain[chain].addressOrDenom, })); return { ...connectContractsMap(attachContractsMap(existingContractAddresses, hypERC20factories), multiProvider), ...deployedContractsMap, }; } function displayWarpDeployPlan(deployConfig) { logBlue('\nWarp Route Deployment Plan'); logGray('=========================='); log(`📋 Token Standard: ${deployConfig.isNft ? 'ERC721' : 'ERC20'}`); const { transformedDeployConfig, transformedIsmConfigs } = transformDeployConfigForDisplay(deployConfig); log('📋 Warp Route Config:'); logTable(transformedDeployConfig); objMap(transformedIsmConfigs, (chain, ismConfigs) => { log(`📋 ${chain} ISM Config(s):`); ismConfigs.forEach((ismConfig) => { logTable(ismConfig); }); }); } function transformDeployConfigForDisplay(deployConfig) { const transformedIsmConfigs = {}; const transformedDeployConfig = objMap(deployConfig, (chain, config) => { if (config.interchainSecurityModule) transformedIsmConfigs[chain] = transformIsmConfigForDisplay(config.interchainSecurityModule); return { 'NFT?': config.isNft ?? false, Type: config.type, Owner: config.owner, Mailbox: config.mailbox, 'ISM Config(s)': config.interchainSecurityModule ? 'See table(s) below.' : 'No ISM config(s) specified.', }; }); return { transformedDeployConfig, transformedIsmConfigs, }; } function transformIsmConfigForDisplay(ismConfig) { const ismConfigs = []; switch (ismConfig.type) { case IsmType.AGGREGATION: ismConfigs.push({ Type: ismConfig.type, Threshold: ismConfig.threshold, Modules: 'See table(s) below.', }); ismConfig.modules.forEach((module) => { ismConfigs.push(...transformIsmConfigForDisplay(module)); }); return ismConfigs; case IsmType.ROUTING: return [ { Type: ismConfig.type, Owner: ismConfig.owner, 'Owner Overrides': ismConfig.ownerOverrides ?? 'Undefined', Domains: 'See warp config for domain specification.', }, ]; case IsmType.FALLBACK_ROUTING: return [ { Type: ismConfig.type, Owner: ismConfig.owner, 'Owner Overrides': ismConfig.ownerOverrides ?? 'Undefined', Domains: 'See warp config for domain specification.', }, ]; case IsmType.MERKLE_ROOT_MULTISIG: return [ { Type: ismConfig.type, Validators: ismConfig.validators, Threshold: ismConfig.threshold, }, ]; case IsmType.MESSAGE_ID_MULTISIG: return [ { Type: ismConfig.type, Validators: ismConfig.validators, Threshold: ismConfig.threshold, }, ]; case IsmType.OP_STACK: return [ { Type: ismConfig.type, Origin: ismConfig.origin, 'Native Bridge': ismConfig.nativeBridge, }, ]; case IsmType.PAUSABLE: return [ { Type: ismConfig.type, Owner: ismConfig.owner, 'Paused ?': ismConfig.paused, 'Owner Overrides': ismConfig.ownerOverrides ?? 'Undefined', }, ]; case IsmType.TRUSTED_RELAYER: return [ { Type: ismConfig.type, Relayer: ismConfig.relayer, }, ]; default: return [ismConfig]; } } /** * Submits a set of transactions to the specified chain and outputs transaction receipts */ async function submitWarpApplyTransactions(params, chainTransactions) { // Create mapping of chain ID to chain name for all chains in warpDeployConfig const chains = Object.keys(params.warpDeployConfig); const chainIdToName = Object.fromEntries(chains.map((chain) => [ params.context.multiProvider.getChainId(chain), chain, ])); await promiseObjAll(objMap(chainTransactions, async (chainId, transactions) => { try { await retryAsync(async () => { const chain = chainIdToName[chainId]; const submitter = await getWarpApplySubmitter({ chain, context: params.context, strategyUrl: params.strategyUrl, }); const transactionReceipts = await submitter.submit(...transactions); if (transactionReceipts) { const receiptPath = `${params.receiptsDir}/${chain}-${submitter.txSubmitterType}-${Date.now()}-receipts.json`; writeYamlOrJson(receiptPath, transactionReceipts); logGreen(`Transactions receipts successfully written to ${receiptPath}`); } }, 5, // attempts 100); } catch (e) { logBlue(`Error in submitWarpApplyTransactions`, e); console.dir(transactions); } })); } /** * Helper function to get warp apply specific submitter. * * @returns the warp apply submitter */ async function getWarpApplySubmitter({ chain, context, strategyUrl, }) { const { multiProvider } = context; const submissionStrategy = strategyUrl ? readChainSubmissionStrategy(strategyUrl)[chain] : { submitter: { chain, type: TxSubmitterType.JSON_RPC, }, }; return getSubmitterBuilder({ submissionStrategy, multiProvider, }); } //# sourceMappingURL=warp.js.map