UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

478 lines 27.8 kB
// import { expect } from 'chai'; import { compareVersions } from 'compare-versions'; import { UINT_256_MAX } from 'starknet'; import { zeroAddress } from 'viem'; import { GasRouter__factory, IERC20__factory, MailboxClient__factory, MovableCollateralRouter__factory, ProxyAdmin__factory, TokenRouter__factory, } from '@hyperlane-xyz/core'; import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js'; import { addressToBytes32, assert, deepEquals, difference, isObjEmpty, normalizeAddressEvm, objMap, promiseObjAll, rootLogger, } from '@hyperlane-xyz/utils'; import { transferOwnershipTransactions } from '../contracts/contracts.js'; import { HyperlaneModule, } from '../core/AbstractHyperlaneModule.js'; import { isInitialized, proxyAdmin, proxyAdminUpdateTxs, } from '../deploy/proxy.js'; import { ContractVerifier } from '../deploy/verify/ContractVerifier.js'; import { ExplorerLicenseType } from '../deploy/verify/types.js'; import { getEvmHookUpdateTransactions } from '../hook/updates.js'; import { EvmIsmModule } from '../ism/EvmIsmModule.js'; import { resolveRouterMapConfig } from '../router/types.js'; import { extractIsmAndHookFactoryAddresses } from '../utils/ism.js'; import { EvmERC20WarpRouteReader } from './EvmERC20WarpRouteReader.js'; import { hypERC20contracts } from './contracts.js'; import { HypERC20Deployer } from './deploy.js'; import { HypTokenRouterConfigSchema, VERSION_ERROR_MESSAGE, contractVersionMatchesDependency, derivedIsmAddress, isMovableCollateralTokenConfig, } from './types.js'; const getAllowedRebalancingBridgesByDomain = (allowedRebalancingBridgesByDomain) => { return objMap(allowedRebalancingBridgesByDomain, (_domainId, allowedRebalancingBridges) => { return new Set(allowedRebalancingBridges.map((bridgeConfig) => normalizeAddressEvm(bridgeConfig.bridge))); }); }; export class EvmERC20WarpModule extends HyperlaneModule { multiProvider; ccipContractCache; contractVerifier; logger = rootLogger.child({ module: 'EvmERC20WarpModule', }); reader; chainName; chainId; domainId; constructor(multiProvider, args, ccipContractCache, contractVerifier) { super(args); this.multiProvider = multiProvider; this.ccipContractCache = ccipContractCache; this.contractVerifier = contractVerifier; this.reader = new EvmERC20WarpRouteReader(multiProvider, args.chain); this.chainName = this.multiProvider.getChainName(args.chain); this.chainId = multiProvider.getEvmChainId(args.chain); this.domainId = multiProvider.getDomainId(args.chain); this.chainId = multiProvider.getEvmChainId(args.chain); this.contractVerifier ??= new ContractVerifier(multiProvider, {}, coreBuildArtifact, ExplorerLicenseType.MIT); } /** * Retrieves the token router configuration for the specified address. * * @param address - The address to derive the token router configuration from. * @returns A promise that resolves to the token router configuration. */ async read() { return this.reader.deriveWarpRouteConfig(this.args.addresses.deployedTokenRoute); } /** * Updates the Warp Route contract with the provided configuration. * * @param expectedConfig - The configuration for the token router to be updated. * @returns An array of Ethereum transactions that were executed to update the contract, or an error if the update failed. */ async update(expectedConfig) { HypTokenRouterConfigSchema.parse(expectedConfig); const actualConfig = await this.read(); const transactions = []; /** * @remark * The order of operations matter * 1. createOwnershipUpdateTxs() must always be LAST because no updates possible after ownership transferred * 2. createRemoteRoutersUpdateTxs() must always be BEFORE createSetDestinationGasUpdateTxs() because gas enumeration depends on domains */ transactions.push(...(await this.upgradeWarpRouteImplementationTx(actualConfig, expectedConfig)), ...(await this.createIsmUpdateTxs(actualConfig, expectedConfig)), ...(await this.createHookUpdateTxs(actualConfig, expectedConfig)), ...this.createEnrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig), ...this.createUnenrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig), ...this.createSetDestinationGasUpdateTxs(actualConfig, expectedConfig), ...this.createAddRebalancersUpdateTxs(actualConfig, expectedConfig), ...this.createRemoveRebalancersUpdateTxs(actualConfig, expectedConfig), ...(await this.createAddAllowedBridgesUpdateTxs(actualConfig, expectedConfig)), ...this.createRemoveBridgesTxs(actualConfig, expectedConfig), ...this.createOwnershipUpdateTxs(actualConfig, expectedConfig), ...proxyAdminUpdateTxs(this.chainId, this.args.addresses.deployedTokenRoute, actualConfig, expectedConfig)); return transactions; } /** * Create a transaction to update the remote routers for the Warp Route contract. * * @param actualConfig - The on-chain router configuration, including the remoteRouters array. * @param expectedConfig - The expected token router configuration. * @returns A array with a single Ethereum transaction that need to be executed to enroll the routers */ createEnrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig) { const updateTransactions = []; if (!expectedConfig.remoteRouters) { return []; } assert(actualConfig.remoteRouters, 'actualRemoteRouters is undefined'); assert(expectedConfig.remoteRouters, 'actualRemoteRouters is undefined'); const { remoteRouters: actualRemoteRouters } = actualConfig; const { remoteRouters: expectedRemoteRouters } = expectedConfig; const routesToEnroll = Object.entries(expectedRemoteRouters) .map(([domain, rawRouter]) => [ domain, { address: addressToBytes32(rawRouter.address) }, ]) .filter(([domain, expectedRouter]) => { const actualRouter = actualRemoteRouters[domain]; // Enroll if router doesn't exist for domain or has different address return !actualRouter || actualRouter.address !== expectedRouter.address; }) .map(([domain]) => domain); if (routesToEnroll.length === 0) { return updateTransactions; } const contractToUpdate = TokenRouter__factory.connect(this.args.addresses.deployedTokenRoute, this.multiProvider.getProvider(this.domainId)); updateTransactions.push({ chainId: this.chainId, annotation: `Enrolling Router ${this.args.addresses.deployedTokenRoute} on ${this.args.chain}`, to: contractToUpdate.address, data: contractToUpdate.interface.encodeFunctionData('enrollRemoteRouters', [ routesToEnroll.map((k) => Number(k)), routesToEnroll.map((a) => addressToBytes32(expectedRemoteRouters[a].address)), ]), }); return updateTransactions; } createUnenrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig) { const updateTransactions = []; if (!expectedConfig.remoteRouters) { return []; } assert(actualConfig.remoteRouters, 'actualRemoteRouters is undefined'); assert(expectedConfig.remoteRouters, 'actualRemoteRouters is undefined'); const { remoteRouters: actualRemoteRouters } = actualConfig; const { remoteRouters: expectedRemoteRouters } = expectedConfig; const routesToUnenroll = Array.from(difference(new Set(Object.keys(actualRemoteRouters)), new Set(Object.keys(expectedRemoteRouters)))); if (routesToUnenroll.length === 0) { return updateTransactions; } const contractToUpdate = TokenRouter__factory.connect(this.args.addresses.deployedTokenRoute, this.multiProvider.getProvider(this.domainId)); updateTransactions.push({ annotation: `Unenrolling Router ${this.args.addresses.deployedTokenRoute} on ${this.args.chain}`, chainId: this.chainId, to: contractToUpdate.address, data: contractToUpdate.interface.encodeFunctionData('unenrollRemoteRouters(uint32[])', [routesToUnenroll.map((k) => Number(k))]), }); return updateTransactions; } createAddRebalancersUpdateTxs(actualConfig, expectedConfig) { actualConfig.type; if (!isMovableCollateralTokenConfig(expectedConfig) || !isMovableCollateralTokenConfig(actualConfig)) { return []; } if (!expectedConfig.allowedRebalancers) { return []; } const formattedExpectedRebalancers = new Set(expectedConfig.allowedRebalancers.map(normalizeAddressEvm)); const formattedActualRebalancers = new Set((actualConfig.allowedRebalancers ?? []).map(normalizeAddressEvm)); const rebalancersToAdd = Array.from(difference(formattedExpectedRebalancers, formattedActualRebalancers)); if (rebalancersToAdd.length === 0) { return []; } return rebalancersToAdd.map((rebalancerToAdd) => ({ chainId: this.chainId, annotation: `Adding rebalancer role to "${rebalancerToAdd}" on token "${this.args.addresses.deployedTokenRoute}" on chain "${this.chainName}"`, to: this.args.addresses.deployedTokenRoute, data: MovableCollateralRouter__factory.createInterface().encodeFunctionData('addRebalancer(address)', [rebalancerToAdd]), })); } createRemoveRebalancersUpdateTxs(actualConfig, expectedConfig) { actualConfig.type; if (!isMovableCollateralTokenConfig(expectedConfig) || !isMovableCollateralTokenConfig(actualConfig)) { return []; } if (!expectedConfig.allowedRebalancers) { return []; } const formattedExpectedRebalancers = new Set(expectedConfig.allowedRebalancers.map(normalizeAddressEvm)); const formattedActualRebalancers = new Set((actualConfig.allowedRebalancers ?? []).map(normalizeAddressEvm)); const rebalancersToRemove = Array.from(difference(formattedActualRebalancers, formattedExpectedRebalancers)); if (rebalancersToRemove.length === 0) { return []; } return rebalancersToRemove.map((rebalancerToRemove) => ({ chainId: this.chainId, annotation: `Removing rebalancer role from "${rebalancerToRemove}" on token "${this.args.addresses.deployedTokenRoute}" on chain "${this.chainName}"`, to: this.args.addresses.deployedTokenRoute, data: MovableCollateralRouter__factory.createInterface().encodeFunctionData('removeRebalancer(address)', [rebalancerToRemove]), })); } async getAllowedBridgesApprovalTxs(actualConfig, expectedConfig) { if (!isMovableCollateralTokenConfig(expectedConfig) || !isMovableCollateralTokenConfig(actualConfig)) { return []; } if (!expectedConfig.allowedRebalancingBridges) { return []; } const tokensToApproveByAllowedBridge = Object.values(expectedConfig.allowedRebalancingBridges).reduce((acc, allowedBridgesConfigs) => { allowedBridgesConfigs.forEach((bridgeConfig) => { acc[bridgeConfig.bridge] ??= []; acc[bridgeConfig.bridge].push(...(bridgeConfig.approvedTokens ?? [])); }); return acc; }, // allowed bridge -> tokens to approve {}); const filteredTokensToApproveByAllowedBridge = await promiseObjAll(objMap(tokensToApproveByAllowedBridge, async (bridge, tokens) => { const filteredApprovals = []; for (const token of tokens) { const instance = IERC20__factory.connect(token, this.multiProvider.getProvider(this.chainId)); const allowance = await instance.allowance(this.args.addresses.deployedTokenRoute, bridge); if (allowance.toBigInt() !== UINT_256_MAX) { filteredApprovals.push(token); } } return filteredApprovals; })); return Object.entries(filteredTokensToApproveByAllowedBridge).flatMap(([bridge, tokensToApprove]) => tokensToApprove.map((tokenToApprove) => ({ chainId: this.chainId, annotation: `Approving allowed bridge "${bridge}" to spend token "${tokenToApprove}" on behalf of "${this.args.addresses.deployedTokenRoute}" on chain "${this.chainName}"`, to: this.args.addresses.deployedTokenRoute, data: MovableCollateralRouter__factory.createInterface().encodeFunctionData('approveTokenForBridge(address,address)', [tokenToApprove, bridge]), }))); } async createAddAllowedBridgesUpdateTxs(actualConfig, expectedConfig) { if (!isMovableCollateralTokenConfig(expectedConfig) || !isMovableCollateralTokenConfig(actualConfig)) { return []; } if (!expectedConfig.allowedRebalancingBridges) { return []; } const actualAllowedBridges = getAllowedRebalancingBridgesByDomain(resolveRouterMapConfig(this.multiProvider, actualConfig.allowedRebalancingBridges ?? {})); const expectedAllowedBridges = getAllowedRebalancingBridgesByDomain(resolveRouterMapConfig(this.multiProvider, expectedConfig.allowedRebalancingBridges)); const rebalancingBridgesToAddByDomain = objMap(expectedAllowedBridges, (domain, bridges) => { const actualBridges = actualAllowedBridges[domain] ?? new Set(); return Array.from(difference(bridges, actualBridges)); }); const bridgesToAllow = Object.entries(rebalancingBridgesToAddByDomain).flatMap(([domain, allowedBridgesToAdd]) => { return allowedBridgesToAdd.map((bridgeToAdd) => { return { chainId: this.chainId, annotation: `Adding allowed bridge "${bridgeToAdd}" on token "${this.args.addresses.deployedTokenRoute}" on chain "${this.chainName}"`, to: this.args.addresses.deployedTokenRoute, data: MovableCollateralRouter__factory.createInterface().encodeFunctionData('addBridge(uint32,address)', [domain, bridgeToAdd]), }; }); }); const approvalTxs = await this.getAllowedBridgesApprovalTxs(actualConfig, expectedConfig); return [...bridgesToAllow, ...approvalTxs]; } createRemoveBridgesTxs(actualConfig, expectedConfig) { if (!isMovableCollateralTokenConfig(expectedConfig) || !isMovableCollateralTokenConfig(actualConfig)) { return []; } if (!expectedConfig.allowedRebalancingBridges) { return []; } const actualAllowedBridges = getAllowedRebalancingBridgesByDomain(resolveRouterMapConfig(this.multiProvider, actualConfig.allowedRebalancingBridges ?? {})); const expectedAllowedBridges = getAllowedRebalancingBridgesByDomain(resolveRouterMapConfig(this.multiProvider, expectedConfig.allowedRebalancingBridges)); const rebalancingBridgesToAddByDomain = objMap(actualAllowedBridges, (domain, bridges) => { const expectedBridges = expectedAllowedBridges[domain] ?? new Set(); return Array.from(difference(bridges, expectedBridges)); }); return Object.entries(rebalancingBridgesToAddByDomain).flatMap(([domain, allowedBridgesToAdd]) => { return allowedBridgesToAdd.map((bridgeToAdd) => { return { chainId: this.chainId, annotation: `Removing allowed bridge "${bridgeToAdd}" on token "${this.args.addresses.deployedTokenRoute}" on chain "${this.chainName}"`, to: this.args.addresses.deployedTokenRoute, data: MovableCollateralRouter__factory.createInterface().encodeFunctionData('removeBridge(uint32,address)', [domain, bridgeToAdd]), }; }); }); } /** * Create a transaction to update the remote routers for the Warp Route contract. * * @param actualConfig - The on-chain router configuration, including the remoteRouters array. * @param expectedConfig - The expected token router configuration. * @returns A array with a single Ethereum transaction that need to be executed to enroll the routers */ createSetDestinationGasUpdateTxs(actualConfig, expectedConfig) { const updateTransactions = []; if (!expectedConfig.destinationGas) { return []; } assert(actualConfig.destinationGas, 'actualDestinationGas is undefined'); assert(expectedConfig.destinationGas, 'expectedDestinationGas is undefined'); const actualDestinationGas = resolveRouterMapConfig(this.multiProvider, actualConfig.destinationGas); const expectedDestinationGas = resolveRouterMapConfig(this.multiProvider, expectedConfig.destinationGas); if (!deepEquals(actualDestinationGas, expectedDestinationGas)) { const contractToUpdate = GasRouter__factory.connect(this.args.addresses.deployedTokenRoute, this.multiProvider.getProvider(this.domainId)); // Convert { 1: 2, 2: 3, ... } to [{ 1: 2 }, { 2: 3 }] const gasRouterConfigs = []; objMap(expectedDestinationGas, (domain, gas) => { gasRouterConfigs.push({ domain, gas, }); }); updateTransactions.push({ chainId: this.chainId, annotation: `Setting destination gas for ${this.args.addresses.deployedTokenRoute} on ${this.args.chain}`, to: contractToUpdate.address, data: contractToUpdate.interface.encodeFunctionData('setDestinationGas((uint32,uint256)[])', [gasRouterConfigs]), }); } return updateTransactions; } /** * Create transactions to update an existing ISM config, or deploy a new ISM and return a tx to setInterchainSecurityModule * * @param actualConfig - The on-chain router configuration, including the ISM configuration, and address. * @param expectedConfig - The expected token router configuration, including the ISM configuration. * @returns Ethereum transaction that need to be executed to update the ISM configuration. */ async createIsmUpdateTxs(actualConfig, expectedConfig) { const updateTransactions = []; if (!expectedConfig.interchainSecurityModule || expectedConfig.interchainSecurityModule === zeroAddress) { return []; } const actualDeployedIsm = derivedIsmAddress(actualConfig); // Try to update (may also deploy) Ism with the expected config const { deployedIsm: expectedDeployedIsm, updateTransactions: ismUpdateTransactions, } = await this.deployOrUpdateIsm(actualConfig, expectedConfig); // If an ISM is updated in-place, push the update txs updateTransactions.push(...ismUpdateTransactions); // If a new ISM is deployed, push the setInterchainSecurityModule tx if (actualDeployedIsm !== expectedDeployedIsm) { const contractToUpdate = MailboxClient__factory.connect(this.args.addresses.deployedTokenRoute, this.multiProvider.getProvider(this.domainId)); updateTransactions.push({ chainId: this.chainId, annotation: `Setting ISM for Warp Route to ${expectedDeployedIsm}`, to: contractToUpdate.address, data: contractToUpdate.interface.encodeFunctionData('setInterchainSecurityModule', [expectedDeployedIsm]), }); } return updateTransactions; } async createHookUpdateTxs(actualConfig, expectedConfig) { if (!expectedConfig.hook) { return []; } const proxyAdminAddress = expectedConfig.proxyAdmin?.address ?? actualConfig.proxyAdmin?.address; assert(proxyAdminAddress, 'ProxyAdmin address is undefined'); return getEvmHookUpdateTransactions(this.args.addresses.deployedTokenRoute, { actualConfig: actualConfig.hook, expectedConfig: expectedConfig.hook, ccipContractCache: this.ccipContractCache, contractVerifier: this.contractVerifier, evmChainName: this.chainName, hookAndIsmFactories: extractIsmAndHookFactoryAddresses(this.args.addresses), setHookFunctionCallEncoder: (newHookAddress) => MailboxClient__factory.createInterface().encodeFunctionData('setHook', [newHookAddress]), logger: this.logger, mailbox: actualConfig.mailbox, multiProvider: this.multiProvider, proxyAdminAddress, }); } /** * Transfer ownership of an existing Warp route with a given config. * * @param actualConfig - The on-chain router configuration. * @param expectedConfig - The expected token router configuration. * @returns Ethereum transaction that need to be executed to update the owner. */ createOwnershipUpdateTxs(actualConfig, expectedConfig) { return transferOwnershipTransactions(this.multiProvider.getEvmChainId(this.args.chain), this.args.addresses.deployedTokenRoute, actualConfig, expectedConfig, `${expectedConfig.type} Warp Route`); } /** * Updates or deploys the ISM using the provided configuration. * * @returns Object with deployedIsm address, and update Transactions */ async deployOrUpdateIsm(actualConfig, expectedConfig) { assert(expectedConfig.interchainSecurityModule, 'Ism derived incorrectly'); const ismModule = new EvmIsmModule(this.multiProvider, { chain: this.args.chain, config: expectedConfig.interchainSecurityModule, addresses: { ...this.args.addresses, mailbox: expectedConfig.mailbox, deployedIsm: derivedIsmAddress(actualConfig), }, }, this.ccipContractCache, this.contractVerifier); this.logger.info(`Comparing target ISM config with ${this.args.chain} chain`); const updateTransactions = await ismModule.update(expectedConfig.interchainSecurityModule); const { deployedIsm } = ismModule.serialize(); return { deployedIsm, updateTransactions }; } /** * Creates a transaction to upgrade the Warp Route implementation if the package version is below specified version. * * @param actualConfig - The current on-chain configuration * @param expectedConfig - The expected configuration * @returns An array of transactions to upgrade the implementation if needed */ async upgradeWarpRouteImplementationTx(actualConfig, expectedConfig) { const updateTransactions = []; // This should be impossible since we try catch the call to `PACKAGE_VERSION` // in `EvmERC20WarpRouteReader.fetchPackageVersion` assert(actualConfig.contractVersion, 'Actual contract version is undefined'); // Only upgrade if the user specifies a version if (!expectedConfig.contractVersion) { return []; } const comparisonValue = compareVersions(expectedConfig.contractVersion, actualConfig.contractVersion); // Expected version is lower than actual version, no upgrade is possible if (comparisonValue === -1) { throw new Error(`Expected contract version ${expectedConfig.contractVersion} is lower than actual contract version ${actualConfig.contractVersion}`); } // Versions are the same, no upgrade needed if (comparisonValue === 0) { return []; } // You can only upgrade to the contract version (see `PackageVersioned`) // defined by the @hyperlane-xyz/core package assert(contractVersionMatchesDependency(expectedConfig.contractVersion), VERSION_ERROR_MESSAGE); this.logger.info(`Upgrading Warp Route implementation on ${this.args.chain} from ${actualConfig.contractVersion} to ${expectedConfig.contractVersion}`); const deployer = new HypERC20Deployer(this.multiProvider); const constructorArgs = await deployer.constructorArgs(this.chainName, expectedConfig); const implementation = await deployer.deployContractWithName(this.chainName, expectedConfig.type, hypERC20contracts[expectedConfig.type], constructorArgs, undefined, false); const provider = this.multiProvider.getProvider(this.domainId); const proxyAddress = this.args.addresses.deployedTokenRoute; const proxyAdminAddress = await proxyAdmin(provider, proxyAddress); assert(await isInitialized(provider, proxyAddress), 'Proxy is not initialized'); updateTransactions.push({ chainId: this.chainId, annotation: `Upgrading Warp Route implementation on ${this.args.chain}`, to: proxyAdminAddress, data: ProxyAdmin__factory.createInterface().encodeFunctionData('upgrade', [proxyAddress, implementation.address]), }); return updateTransactions; } /** * Deploys the Warp Route. * * @param chain - The chain to deploy the module on. * @param config - The configuration for the token router. * @param multiProvider - The multi-provider instance to use. * @returns A new instance of the EvmERC20WarpHyperlaneModule. */ static async create(params) { const { chain, config, multiProvider, ccipContractCache, contractVerifier, proxyFactoryFactories, } = params; const chainName = multiProvider.getChainName(chain); const deployer = new HypERC20Deployer(multiProvider); const deployedContracts = await deployer.deployContracts(chainName, config); const warpModule = new EvmERC20WarpModule(multiProvider, { addresses: { ...proxyFactoryFactories, deployedTokenRoute: deployedContracts[config.type].address, }, chain, config, }, ccipContractCache, contractVerifier); const actualConfig = await warpModule.read(); if (config.remoteRouters && !isObjEmpty(config.remoteRouters)) { const enrollRemoteTxs = await warpModule.createEnrollRemoteRoutersUpdateTxs(actualConfig, config); // @TODO Remove when EvmERC20WarpModule.create can be used const onlyTxIndex = 0; await multiProvider.sendTransaction(chain, enrollRemoteTxs[onlyTxIndex]); } if (isMovableCollateralTokenConfig(config) && config.allowedRebalancers && config.allowedRebalancers.length !== 0) { const addRebalancerTxs = await warpModule.createAddRebalancersUpdateTxs(actualConfig, config); // @TODO Remove when EvmERC20WarpModule.create can be used for (const tx of addRebalancerTxs) { await multiProvider.sendTransaction(chain, tx); } } if (isMovableCollateralTokenConfig(config) && config.allowedRebalancingBridges && !isObjEmpty(config.allowedRebalancingBridges)) { const addBridgesTxs = await warpModule.createAddAllowedBridgesUpdateTxs(actualConfig, config); // @TODO Remove when EvmERC20WarpModule.create can be used for (const tx of addBridgesTxs) { await multiProvider.sendTransaction(chain, tx); } } return warpModule; } } //# sourceMappingURL=EvmERC20WarpModule.js.map