@hyperlane-xyz/cli
Version:
A command-line utility for common Hyperlane operations
517 lines • 23.9 kB
JavaScript
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