UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

522 lines 28.3 kB
import { MailboxClient__factory, ProxyAdmin__factory, } from '@hyperlane-xyz/core'; import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js'; import { createHookWriter, createIsmWriter, createWarpTokenWriter, validateIsmConfig, } from '@hyperlane-xyz/deploy-sdk'; import { ProtocolType } from '@hyperlane-xyz/provider-sdk'; import { ArtifactState } from '@hyperlane-xyz/provider-sdk/artifact'; import { hookConfigToArtifact, } from '@hyperlane-xyz/provider-sdk/hook'; import { ismConfigToArtifact, } from '@hyperlane-xyz/provider-sdk/ism'; import { TokenType as ProviderTokenType, warpConfigToArtifact, } from '@hyperlane-xyz/provider-sdk/warp'; import { addressToBytes32, assert, isNullish, isObjEmpty, mapAllSettled, mustGet, objFilter, objKeys, objMap, promiseObjAll, rootLogger, } from '@hyperlane-xyz/utils'; import { ExplorerLicenseType } from '../block-explorer/etherscan.js'; import { CCIPContractCache } from '../ccip/utils.js'; import { EvmHookModule } from '../hook/EvmHookModule.js'; import { hookTreeContainsRateLimited } from '../hook/utils.js'; import { EvmIsmModule } from '../ism/EvmIsmModule.js'; import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js'; import { altVmChainLookup } from '../metadata/ChainMetadataManager.js'; import { resolveRouterMapConfig, } from '../router/types.js'; import { EvmWarpModule } from '../token/EvmWarpModule.js'; import { MAX_GAS_OVERHEAD, TokenType, gasOverhead } from '../token/config.js'; import { hypERC20factories } from '../token/contracts.js'; import { HypERC20Deployer, HypERC721Deployer } from '../token/deploy.js'; import { extractIsmAndHookFactoryAddresses, ismTreeContainsRateLimited, } from '../utils/ism.js'; import { HyperlaneProxyFactoryDeployer } from './HyperlaneProxyFactoryDeployer.js'; import { ContractVerifier } from './verify/ContractVerifier.js'; const SUPPORTED_ALTVM_TOKEN_TYPES = new Set([ TokenType.synthetic, TokenType.collateral, TokenType.native, TokenType.crossCollateral, ]); export function validateWarpConfigForAltVM(config, chain) { if (!SUPPORTED_ALTVM_TOKEN_TYPES.has(config.type)) { const supportedTypes = Array.from(SUPPORTED_ALTVM_TOKEN_TYPES).join(', '); throw new Error(`Unsupported token type '${config.type}' for Alt-VM chain '${chain}'.\n` + `Supported token types: ${supportedTypes}.`); } if (config.interchainSecurityModule) { validateIsmConfig(config.interchainSecurityModule, chain, 'warp config'); } let scale; if (typeof config.scale === 'number') { scale = config.scale; } else if (!isNullish(config.scale)) { assert(Number(config.scale.denominator) !== 0, 'scale denominator must be non-zero'); scale = Number(config.scale.numerator) / Number(config.scale.denominator); } const baseConfig = { owner: config.owner, mailbox: config.mailbox, interchainSecurityModule: config.interchainSecurityModule, hook: config.hook, remoteRouters: config.remoteRouters, destinationGas: config.destinationGas, scale, }; switch (config.type) { case TokenType.collateral: { if (!config.token) { throw new Error(`Collateral token config for chain '${chain}' must specify 'token' address`); } const result = { ...baseConfig, type: ProviderTokenType.collateral, token: config.token, }; return result; } case TokenType.synthetic: { const result = { ...baseConfig, type: ProviderTokenType.synthetic, name: config.name, symbol: config.symbol, decimals: config.decimals, metadataUri: config.metadataUri, }; return result; } case TokenType.crossCollateral: { if (!config.token) { throw new Error(`Cross-collateral token config for chain '${chain}' must specify 'token' address`); } const result = { ...baseConfig, type: ProviderTokenType.crossCollateral, token: config.token, crossCollateralRouters: config.crossCollateralRouters, }; return result; } case TokenType.native: { const result = { ...baseConfig, type: ProviderTokenType.native, }; return result; } default: throw new Error(`Unhandled token type '${config.type}' for Alt-VM chain '${chain}'.`); } } // Subclass that injects rate-limited hook deployment between configureClients and // transferOwnership so that setHook() is called while the deployer signer still owns the token. class RateLimitedHookERC20Deployer extends HypERC20Deployer { preTransferFn; constructor(multiProvider, ismFactory, contractVerifier, preTransferFn) { super(multiProvider, ismFactory, contractVerifier); this.preTransferFn = preTransferFn; } async beforeTransferOwnership(contractsMap) { await this.preTransferFn(objMap(contractsMap, (_, contracts) => getRouter(contracts).address)); } } export async function executeWarpDeploy(warpDeployConfig, multiProvider, altVmSigners, registryAddresses, apiKeys) { const contractVerifier = new ContractVerifier(multiProvider, apiKeys, coreBuildArtifact, ExplorerLicenseType.MIT); const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider, contractVerifier); // Capture ISM configs that contain a RATE_LIMITED node before resolveWarpIsmAndHook // runs — that function replaces each chain's ISM field with the deployed address, // but RATE_LIMITED ISMs (and any composite ISM containing one) are skipped there // because the constructor requires the token address (recipient), which doesn't // exist yet. They are wired inside TokenDeployer.deploy() before ownership is // transferred so that setInterchainSecurityModule succeeds regardless of config.owner. const rateLimitedSnapshot = {}; for (const [chain, config] of Object.entries(warpDeployConfig)) { if (typeof config.interchainSecurityModule !== 'object') continue; const ism = config.interchainSecurityModule; if (!ismTreeContainsRateLimited(ism)) continue; const protocol = multiProvider.getProtocol(chain); assert(protocol === ProtocolType.Ethereum || protocol === ProtocolType.Tron, `RateLimitedIsm is only supported on Ethereum and Tron chains, but chain ${chain} has protocol ${protocol}`); // Store the full ISM tree as-is; recipient + owner defaults are applied // uniformly in setRateLimitedIsms via setRateLimitedIsmRecipient. rateLimitedSnapshot[chain] = ism; } // Hooks containing RATE_LIMITED need the token router address as sender, so they are deferred // until after token deployment. resolveWarpIsmAndHook populates this map (EVM/Tron only) and // returns undefined for those hooks, causing them to be set later via setHook(). const rateLimitedHookSnapshots = {}; // 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(warpDeployConfig, multiProvider, altVmSigners, registryAddresses, ismFactoryDeployer, contractVerifier, rateLimitedHookSnapshots); // Initialize with unsupported chains so that they are enrolled let deployedContracts = objMap(objFilter(warpDeployConfig, (_chain, config) => !!config.foreignDeployment), (chain, config) => { assert(config.foreignDeployment, `Expected foreignDeployment field to be defined on ${chain} after filtering`); return config.foreignDeployment; }); // get unique list of protocols const protocols = Array.from(new Set(Object.keys(modifiedConfig).map((chainName) => multiProvider.getProtocol(chainName)))); for (const protocol of protocols) { const protocolSpecificConfig = objFilter(modifiedConfig, (chainName, config) => multiProvider.getProtocol(chainName) === protocol && !config.foreignDeployment); if (isObjEmpty(protocolSpecificConfig)) { continue; } switch (protocol) { case ProtocolType.Tron: case ProtocolType.Ethereum: { const ismFactory = HyperlaneIsmFactory.fromAddressesMap(registryAddresses, multiProvider, undefined, contractVerifier); assert(!warpDeployConfig.isNft || isObjEmpty(rateLimitedHookSnapshots), 'RATE_LIMITED hooks are not supported for NFT warp routes (HypERC721Deployer has no beforeTransferOwnership override)'); const deployer = warpDeployConfig.isNft ? new HypERC721Deployer(multiProvider) : isObjEmpty(rateLimitedHookSnapshots) ? new HypERC20Deployer(multiProvider, ismFactory, contractVerifier) // TODO: replace with EvmERC20WarpModule : new RateLimitedHookERC20Deployer(multiProvider, ismFactory, contractVerifier, // Called BEFORE transferOwnership — deployer signer still owns the token here. async (deployedTokens) => { const chainSnapshots = objFilter(rateLimitedHookSnapshots, (chain, _v) => chain in deployedTokens); if (isObjEmpty(chainSnapshots)) return; const deployedHooks = await deployAndWireRateLimitedHooks(chainSnapshots, deployedTokens, multiProvider, contractVerifier); for (const [chain, hookAddress] of Object.entries(deployedHooks)) { warpDeployConfig[chain].hook = hookAddress; } }); const chainSet = new Set(Object.keys(protocolSpecificConfig)); const rateLimitedForBatch = objFilter(rateLimitedSnapshot, (_chain, _ismConfig) => chainSet.has(_chain)); const evmContracts = await deployer.deploy(protocolSpecificConfig, rateLimitedForBatch); deployedContracts = { ...deployedContracts, ...objMap(evmContracts, (_, contracts) => getRouter(contracts).address), }; break; } default: { const chainLookup = altVmChainLookup(multiProvider); const deployResults = {}; for (const chain of objKeys(protocolSpecificConfig)) { const config = mustGet(protocolSpecificConfig, chain); const signer = mustGet(altVmSigners, chain); const chainMetadata = chainLookup.getChainMetadata(chain); const writer = createWarpTokenWriter(chainMetadata, chainLookup, signer); const artifact = warpConfigToArtifact(validateWarpConfigForAltVM(config, chain), chainLookup); const [deployed] = await writer.create(artifact); deployResults[chain] = deployed.deployed.address; } deployedContracts = { ...deployedContracts, ...deployResults, }; break; } } } return deployedContracts; } async function deployAndWireRateLimitedHooks(snapshots, deployedTokens, multiProvider, contractVerifier) { return promiseObjAll(objMap(snapshots, async (chain, { hookConfig, chainAddresses, ccipContractCache, proxyAdminAddress }) => { const tokenAddress = mustGet(deployedTokens, chain); assert(chainAddresses, `No registry addresses for ${chain}`); const resolvedProxyAdminAddress = proxyAdminAddress ?? (await multiProvider.handleDeploy(chain, new ProxyAdmin__factory(), [])).address; const evmHookModule = await EvmHookModule.create({ chain, multiProvider, coreAddresses: { mailbox: chainAddresses.mailbox, proxyAdmin: resolvedProxyAdminAddress, rateLimitedSender: tokenAddress, }, config: hookConfig, ccipContractCache, proxyFactoryFactories: extractIsmAndHookFactoryAddresses(chainAddresses), contractVerifier, }); const { deployedHook } = evmHookModule.serialize(); assert(deployedHook, `Failed to get deployed hook address for ${chain}`); rootLogger.info(`Wiring RateLimitedHook ${deployedHook} to token ${tokenAddress} on ${chain}`); const txOverrides = multiProvider.getTransactionOverrides(chain); const signer = multiProvider.getSigner(chain); const token = MailboxClient__factory.connect(tokenAddress, signer); await multiProvider.handleTx(chain, token.setHook(deployedHook, txOverrides)); return deployedHook; })); } async function resolveWarpIsmAndHook(warpConfig, multiProvider, altVmSigners, registryAddresses, ismFactoryDeployer, contractVerifier, rateLimitedHookSnapshots) { return promiseObjAll(objMap(warpConfig, async (chain, config) => { const ccipContractCache = new CCIPContractCache(registryAddresses); const chainAddresses = registryAddresses[chain]; if (!chainAddresses) { throw new Error(`Registry factory addresses not found for ${chain}.`); } const ism = await createWarpIsm({ ccipContractCache, chain, chainAddresses, multiProvider, altVmSigners, contractVerifier, ismFactoryDeployer, warpConfig: config, }); // TODO write test const hook = await createWarpHook({ ccipContractCache, chain, chainAddresses, multiProvider, altVmSigners, contractVerifier, ismFactoryDeployer, warpConfig: config, rateLimitedHookSnapshots, }); // Spread instead of mutating config in place — the caller holds a reference // to warpDeployConfig[chain] and uses it for registry persistence; mutating // would wipe the RATE_LIMITED stanza from the persisted YAML. return { ...config, interchainSecurityModule: ism, hook, }; })); } /** * Deploys the Warp ISM for a given config * * @returns The deployed ism address */ async function createWarpIsm({ ccipContractCache, chain, chainAddresses, multiProvider, altVmSigners, contractVerifier, warpConfig, }) { const { interchainSecurityModule } = warpConfig; if (!interchainSecurityModule || typeof interchainSecurityModule === 'string') { rootLogger.info(`Config Ism is ${!interchainSecurityModule ? 'empty' : interchainSecurityModule}, skipping deployment.`); return interchainSecurityModule; } // RateLimitedIsm has a chicken-and-egg problem: the constructor requires the // token (recipient) address, but ISMs are deployed here — before the token exists. // We skip any ISM tree that contains a RATE_LIMITED node and deploy it later in // setRateLimitedIsms() (after the token is deployed), then wire it up via // setInterchainSecurityModule(). if (ismTreeContainsRateLimited(interchainSecurityModule)) { rootLogger.info(`Skipping ISM deployment for ${chain} (contains RateLimitedIsm), will deploy after token.`); return undefined; } rootLogger.info(`Loading registry factory addresses for ${chain}...`); rootLogger.info(`Creating ${interchainSecurityModule.type} ISM for token on ${chain} chain...`); rootLogger.info(`Finished creating ${interchainSecurityModule.type} ISM for token on ${chain} chain.`); const protocolType = multiProvider.getProtocol(chain); switch (protocolType) { case ProtocolType.Tron: case ProtocolType.Ethereum: { const evmIsmModule = await EvmIsmModule.create({ chain, mailbox: chainAddresses.mailbox, multiProvider: multiProvider, proxyFactoryFactories: extractIsmAndHookFactoryAddresses(chainAddresses), config: interchainSecurityModule, ccipContractCache, contractVerifier, }); const { deployedIsm } = evmIsmModule.serialize(); return deployedIsm; } default: { const signer = mustGet(altVmSigners, chain); const chainLookup = altVmChainLookup(multiProvider); const chainMetadata = chainLookup.getChainMetadata(chain); const writer = createIsmWriter(chainMetadata, chainLookup, signer); const artifact = ismConfigToArtifact( // FIXME: not all ISM types are supported yet interchainSecurityModule, chainLookup); const [deployed] = await writer.create(artifact); return deployed.deployed.address; } } } async function createWarpHook({ ccipContractCache, chain, chainAddresses, multiProvider, altVmSigners, contractVerifier, warpConfig, rateLimitedHookSnapshots, }) { const { hook } = warpConfig; if (!hook || typeof hook === 'string') { rootLogger.info(`Config Hook is ${!hook ? 'empty' : hook}, skipping deployment.`); return hook; } // RATE_LIMITED hooks need the token router address as sender — defer until post-token deploy. // Only EVM/Tron support EvmHookModule; foreignDeployment and non-EVM chains cannot wire the hook. if (hookTreeContainsRateLimited(hook)) { assert(!warpConfig.foreignDeployment, `RATE_LIMITED hook configured on ${chain} but it is a foreignDeployment — hook cannot be wired post-deploy`); const protocol = multiProvider.getProtocol(chain); assert(protocol === ProtocolType.Ethereum || protocol === ProtocolType.Tron, `RATE_LIMITED hook is only supported on EVM/Tron chains; ${chain} uses protocol ${protocol}`); rootLogger.info(`RATE_LIMITED hook on ${chain} — deferring deployment until after token deployment`); rateLimitedHookSnapshots[chain] = { hookConfig: hook, chainAddresses, ccipContractCache, proxyAdminAddress: warpConfig.proxyAdmin?.address, }; return undefined; } rootLogger.info(`Loading registry factory addresses for ${chain}...`); rootLogger.info(`Creating ${hook.type} Hook for token on ${chain} chain...`); const protocolType = multiProvider.getProtocol(chain); switch (protocolType) { case ProtocolType.Tron: case ProtocolType.Ethereum: { rootLogger.info(`Loading registry factory addresses for ${chain}...`); rootLogger.info(`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 multiProvider.handleDeploy(chain, new ProxyAdmin__factory(), [])) .address; const evmHookModule = await EvmHookModule.create({ chain, multiProvider: multiProvider, coreAddresses: { mailbox: chainAddresses.mailbox, proxyAdmin: proxyAdminAddress, }, config: hook, ccipContractCache, contractVerifier, proxyFactoryFactories: extractIsmAndHookFactoryAddresses(chainAddresses), }); rootLogger.info(`Finished creating ${hook.type} Hook for token on ${chain} chain.`); const { deployedHook } = evmHookModule.serialize(); return deployedHook; } default: { const signer = mustGet(altVmSigners, chain); const chainLookup = altVmChainLookup(multiProvider); const metadata = multiProvider.getChainMetadata(chain); // Deploy new hook using artifact writer with mailbox context const writer = createHookWriter(metadata, chainLookup, signer, { mailbox: chainAddresses.mailbox, }); const artifact = hookConfigToArtifact(hook, chainLookup); const [deployed] = await writer.create(artifact); return deployed.deployed.address; } } } export async function enrollCrossChainRouters({ multiProvider, altVmSigners, registryAddresses, warpDeployConfig, }, deployedContracts) { rootLogger.info(`Start enrolling cross chain routers`); const resolvedConfigMap = objMap(warpDeployConfig, (_, config) => ({ gas: gasOverhead(config.type), ...config, })); const supportedChains = Object.keys(objFilter(resolvedConfigMap, (_, config) => !config.foreignDeployment && config.type !== TokenType.collateralDepositAddress)); // Process all chains in parallel since they are independent const { fulfilled, rejected } = await mapAllSettled(supportedChains, async (currentChain) => { const protocol = multiProvider.getProtocol(currentChain); // Start with user-specified remote routers (for chains not in the deployment) const userRemoteRouters = objMap(resolveRouterMapConfig(multiProvider, resolvedConfigMap[currentChain].remoteRouters ?? {}), (_, value) => ({ address: addressToBytes32(value.address) })); // Merge: deployed routers take precedence over user-specified const remoteRouters = { ...userRemoteRouters, ...Object.fromEntries(Object.entries(deployedContracts) .filter(([chain, _address]) => chain !== currentChain) .map(([chain, address]) => [ multiProvider.getDomainId(chain).toString(), { address: addressToBytes32(address), }, ])), }; // Start with user-specified destination gas const userDestinationGas = resolveRouterMapConfig(multiProvider, resolvedConfigMap[currentChain].destinationGas ?? {}); // Default to MAX_GAS_OVERHEAD for user-specified remote routers without explicit destinationGas const defaultGasForUserRouters = objMap(userRemoteRouters, (domainId) => userDestinationGas[domainId] ?? MAX_GAS_OVERHEAD.toString()); // Merge: deployed chain gas takes precedence over defaults and user-specified const destinationGas = { ...defaultGasForUserRouters, ...Object.fromEntries(Object.entries(deployedContracts) .filter(([chain, _address]) => chain !== currentChain) .map(([chain, _address]) => [ multiProvider.getDomainId(chain).toString(), resolvedConfigMap[chain].gas.toString(), ])), }; for (const domainId of Object.keys(remoteRouters)) { rootLogger.debug(`Creating enroll remote router transactions with remote domain id ${domainId} and address ${remoteRouters[domainId]} on chain ${currentChain}`); } let transactions = []; switch (protocol) { case ProtocolType.Tron: case ProtocolType.Ethereum: { const { domainRoutingIsmFactory, incrementalDomainRoutingIsmFactory, staticMerkleRootMultisigIsmFactory, staticMessageIdMultisigIsmFactory, staticAggregationIsmFactory, staticAggregationHookFactory, staticMerkleRootWeightedMultisigIsmFactory, staticMessageIdWeightedMultisigIsmFactory, } = registryAddresses[currentChain]; const evmWarpModule = new EvmWarpModule(multiProvider, { chain: currentChain, config: resolvedConfigMap[currentChain], addresses: { deployedTokenRoute: deployedContracts[currentChain], domainRoutingIsmFactory, incrementalDomainRoutingIsmFactory, staticMerkleRootMultisigIsmFactory, staticMessageIdMultisigIsmFactory, staticAggregationIsmFactory, staticAggregationHookFactory, staticMerkleRootWeightedMultisigIsmFactory, staticMessageIdWeightedMultisigIsmFactory, }, }); const actualConfig = await evmWarpModule.read(); const expectedConfig = { ...actualConfig, owner: resolvedConfigMap[currentChain].owner, remoteRouters, destinationGas, // For cross-protocol routes (EVM+SVM/Cosmos), the EVM deployer // never enrolls non-EVM remote routers, so TokenRouter.domains()=[] // at this point. The reader derives RoutingFee.feeContracts from // enrolled domains, returning {} which fails // RoutingFeeInputConfigSchema validation. Use the deploy config's // tokenFee (non-empty feeContracts) so validation passes. // EvmTokenFeeModule.update() reads actual on-chain state via // routingDestinations and confirms no change is needed. ...(resolvedConfigMap[currentChain].tokenFee && { tokenFee: resolvedConfigMap[currentChain].tokenFee, }), }; transactions = await evmWarpModule.update(expectedConfig, { routingDestinations: Object.keys(remoteRouters).map((domain) => parseInt(domain, 10)), }); break; } default: { const signer = mustGet(altVmSigners, currentChain); const chainLookup = altVmChainLookup(multiProvider); const chainMetadata = chainLookup.getChainMetadata(currentChain); const writer = createWarpTokenWriter(chainMetadata, chainLookup, signer); const expectedConfig = { ...resolvedConfigMap[currentChain], remoteRouters, destinationGas, }; const artifact = warpConfigToArtifact(validateWarpConfigForAltVM(expectedConfig, currentChain), chainLookup); const deployedArtifact = { artifactState: ArtifactState.DEPLOYED, config: artifact.config, deployed: { address: deployedContracts[currentChain] }, }; transactions = await writer.update(deployedArtifact); } } rootLogger.debug(`Created enroll router update transactions for chain ${currentChain}`); return { chain: currentChain, transactions }; }, (chain) => chain); // Process settled results and collect transactions const updateTransactions = {}; const errors = []; for (const [, result] of fulfilled) { if (result.transactions.length) { updateTransactions[result.chain] = result.transactions; } } for (const [chain, error] of rejected) { rootLogger.error(`Failed to create enroll router transactions for chain ${chain}: ${error.message}`); errors.push(`${chain}: ${error.message}`); } if (errors.length > 0) { throw new Error(`Failed to create router enrollment transactions for ${errors.length} chain(s): ${errors.join('; ')}`); } return updateTransactions; } function getRouter(contracts) { for (const key of objKeys(hypERC20factories)) { if (contracts[key]) return contracts[key]; } throw new Error('No matching contract found.'); } //# sourceMappingURL=warp.js.map