@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
829 lines • 67.6 kB
JavaScript
// import { expect } from 'chai';
import { compareVersions } from 'compare-versions';
import { constants } from 'ethers';
import { UINT_256_MAX } from 'starknet';
import { CrossCollateralRouter__factory, EverclearTokenBridge__factory, GasRouter__factory, IERC20__factory, MailboxClient__factory, MovableCollateralRouter__factory, PredicateRouterWrapper__factory, ProxyAdmin__factory, StaticAggregationHook__factory, StaticAggregationHookFactory__factory, TokenBridgeCctpV2__factory, TokenRouter__factory, } from '@hyperlane-xyz/core';
import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js';
import { ZERO_ADDRESS_HEX_32, addressToBytes32, assert, deepEquals, difference, eqAddress, isAddressEvm, isNullish, isObjEmpty, isZeroishAddress, normalizeAddressEvm, objDiff, objFilter, objKeys, objMap, promiseObjAll, rootLogger, } from '@hyperlane-xyz/utils';
import { ExplorerLicenseType } from '../block-explorer/etherscan.js';
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 { EvmTokenFeeModule } from '../fee/EvmTokenFeeModule.js';
import { mergeCrossCollateralRouters } from '../fee/crossCollateralUtils.js';
import { TokenFeeType } from '../fee/types.js';
import { getEvmHookUpdateTransactions } from '../hook/updates.js';
import { stripPredicateSubHook } from '../hook/utils.js';
import { OnchainHookType } from '../hook/types.js';
import { EvmIsmModule } from '../ism/EvmIsmModule.js';
import { PredicateWrapperDeployer } from '../predicate/PredicateDeployer.js';
import { resolveRouterMapConfig } from '../router/types.js';
import { scalesEqual } from '../utils/decimals.js';
import { IsmType } from '../ism/types.js';
import { extractIsmAndHookFactoryAddresses, ismTreeContainsRateLimited, setRateLimitedIsmRecipient, } from '../utils/ism.js';
import { CCTP_PPM_STORAGE_VERSION, EvmWarpRouteReader, } from './EvmWarpRouteReader.js';
import { EvmXERC20Module } from './EvmXERC20Module.js';
import { TokenType } from './config.js';
import { resolveTokenFeeAddress } from './configUtils.js';
import { hypERC20contracts } from './contracts.js';
import { HypERC20Deployer } from './deploy.js';
import { HypTokenRouterConfigSchema, PredicateWrapperConfigSchema, VERSION_ERROR_MESSAGE, contractVersionMatchesDependency, derivedHookAddress, derivedIsmAddress, isCctpTokenConfig, isEverclearTokenBridgeConfig, isMovableCollateralTokenConfig, isCrossCollateralTokenConfig, isOftTokenConfig, isXERC20TokenConfig, } from './types.js';
const getAllowedRebalancingBridgesByDomain = (allowedRebalancingBridgesByDomain) => {
return objMap(allowedRebalancingBridgesByDomain, (_domainId, allowedRebalancingBridges) => {
return new Set(allowedRebalancingBridges.map((bridgeConfig) => normalizeAddressEvm(bridgeConfig.bridge)));
});
};
export class EvmWarpModule extends HyperlaneModule {
multiProvider;
ccipContractCache;
contractVerifier;
logger = rootLogger.child({
module: 'EvmWarpModule',
});
reader;
chainName;
chainId;
domainId;
constructor(multiProvider, args, ccipContractCache, contractVerifier) {
super(args);
this.multiProvider = multiProvider;
this.ccipContractCache = ccipContractCache;
this.contractVerifier = contractVerifier;
this.reader = new EvmWarpRouteReader(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() {
const config = await this.reader.deriveWarpRouteConfig(this.args.addresses.deployedTokenRoute);
// recipient is always the token address itself — implicit in the warp
// context, so omit it from read() output to keep configs clean.
// Mutate in place to preserve the DerivedIsmConfig type (WithAddress<...>).
const stripRecipient = (node) => {
if (typeof node !== 'object' || node === null)
return;
const n = node;
if (n.type === IsmType.RATE_LIMITED) {
delete n.recipient;
return;
}
if (Array.isArray(n.modules))
n.modules.forEach(stripRecipient);
if (typeof n.domains === 'object' && n.domains !== null)
Object.values(n.domains).forEach(stripRecipient);
if (n.lowerIsm)
stripRecipient(n.lowerIsm);
if (n.upperIsm)
stripRecipient(n.upperIsm);
};
const ism = config.interchainSecurityModule;
if (typeof ism !== 'string' && ismTreeContainsRateLimited(ism))
stripRecipient(ism);
return config;
}
/**
* Updates the Warp Route contract with the provided configuration.
*
* IMPORTANT — irreversible side effects when expectedConfig includes `predicateWrapper`:
* The PredicateRouterWrapper contract is deployed on-chain during planning (before this
* method returns). If the returned transactions are never submitted, the wrapper is
* orphaned. See PredicateWrapperDeployer.deployAndConfigure for details.
*
* @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, tokenReaderParams) {
HypTokenRouterConfigSchema.parse(expectedConfig);
const actualConfig = await this.read();
const transactions = [];
let xerc20Txs = [];
if (isXERC20TokenConfig(expectedConfig)) {
const { module, config } = await EvmXERC20Module.fromWarpRouteConfig(this.multiProvider, this.chainName, expectedConfig, this.args.addresses.deployedTokenRoute);
xerc20Txs = await module.update(config);
}
/**
* @remark
* The order of operations matter
* 1. createOwnershipUpdateTxs() must always be LAST because no updates possible after ownership transferred
* 2. createEnrollRemoteRoutersUpdateTxs() must be BEFORE createSetDestinationGasUpdateTxs()
* because GasRouter requires routers to be enrolled before setting destination gas
* 3. createHookAndPredicateUpdateTxs() handles hook + predicate wrapper together so the
* pending new hook address is threaded through without leaking into other method signatures
*/
transactions.push(...(await this.upgradeWarpRouteImplementationTx(actualConfig, expectedConfig)), ...(await this.createIsmUpdateTxs(actualConfig, expectedConfig)), ...(await this.createHookAndPredicateUpdateTxs(actualConfig, expectedConfig)), ...(await this.createTokenFeeUpdateTxs(actualConfig, expectedConfig, tokenReaderParams)), ...this.createUnenrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig), ...this.createEnrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig),
// MC unenroll before enroll for consistency with remote routers.
// MC enrollment must come before gas setting so that MC-only domains
...this.createUnenrollCrossCollateralRoutersTxs(actualConfig, expectedConfig), ...this.createEnrollCrossCollateralRoutersTxs(actualConfig, expectedConfig), ...this.createSetDestinationGasUpdateTxs(actualConfig, expectedConfig), ...this.createAddRebalancersUpdateTxs(actualConfig, expectedConfig), ...this.createRemoveRebalancersUpdateTxs(actualConfig, expectedConfig), ...(await this.createAddAllowedBridgesUpdateTxs(actualConfig, expectedConfig)), ...this.createRemoveBridgesTxs(actualConfig, expectedConfig), ...this.createAddRemoteOutputAssetsTxs(actualConfig, expectedConfig), ...this.createRemoveRemoteOutputAssetsTxs(actualConfig, expectedConfig), ...this.createUpdateEverclearFeeParamsTxs(actualConfig, expectedConfig), ...this.createRemoveEverclearFeeParamsTxs(actualConfig, expectedConfig), ...this.createSetMaxFeePpmTxs(actualConfig, expectedConfig), ...xerc20Txs, ...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) {
// OFT contracts don't have Router interface — no remote router enrollment
if (isOftTokenConfig(expectedConfig)) {
return [];
}
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) {
// OFT contracts don't have Router interface — no remote router unenrollment
if (isOftTokenConfig(expectedConfig)) {
return [];
}
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) {
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) {
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]),
}));
}
/**
* Create transactions to enroll CrossCollateralRouter routers.
*/
createEnrollCrossCollateralRoutersTxs(actualConfig, expectedConfig) {
if (!isCrossCollateralTokenConfig(expectedConfig) ||
!isCrossCollateralTokenConfig(actualConfig)) {
return [];
}
if (!expectedConfig.crossCollateralRouters) {
return [];
}
const actualEnrolled = resolveRouterMapConfig(this.multiProvider, actualConfig.crossCollateralRouters ?? {});
const expectedEnrolled = resolveRouterMapConfig(this.multiProvider, expectedConfig.crossCollateralRouters);
const domainsToEnroll = [];
const routersToEnroll = [];
for (const [domain, expectedRouters] of Object.entries(expectedEnrolled)) {
const domainId = Number(domain);
const actualRouters = new Set((actualEnrolled[domainId] ?? []).map((router) => this.toCanonicalRouterId(router)));
for (const router of expectedRouters) {
const canonicalRouter = this.toCanonicalRouterId(router);
if (!actualRouters.has(canonicalRouter)) {
domainsToEnroll.push(domainId);
routersToEnroll.push(canonicalRouter);
}
}
}
if (domainsToEnroll.length === 0) {
return [];
}
return [
{
chainId: this.chainId,
annotation: `Enrolling ${domainsToEnroll.length} CrossCollateralRouter routers on ${this.args.addresses.deployedTokenRoute} on ${this.chainName}`,
to: this.args.addresses.deployedTokenRoute,
data: CrossCollateralRouter__factory.createInterface().encodeFunctionData('enrollCrossCollateralRouters', [domainsToEnroll, routersToEnroll]),
},
];
}
/**
* Create transactions to unenroll CrossCollateralRouter routers.
*/
createUnenrollCrossCollateralRoutersTxs(actualConfig, expectedConfig) {
if (!isCrossCollateralTokenConfig(expectedConfig) ||
!isCrossCollateralTokenConfig(actualConfig)) {
return [];
}
const expectedCrossCollateralRouters = expectedConfig.crossCollateralRouters ?? {};
const actualEnrolled = resolveRouterMapConfig(this.multiProvider, actualConfig.crossCollateralRouters ?? {});
const expectedEnrolled = resolveRouterMapConfig(this.multiProvider, expectedCrossCollateralRouters);
const domainsToUnenroll = [];
const routersToUnenroll = [];
for (const [domain, actualRouters] of Object.entries(actualEnrolled)) {
const domainId = Number(domain);
const expectedRouters = new Set((expectedEnrolled[domainId] ?? []).map((router) => this.toCanonicalRouterId(router)));
for (const router of actualRouters) {
const canonicalRouter = this.toCanonicalRouterId(router);
if (!expectedRouters.has(canonicalRouter)) {
domainsToUnenroll.push(domainId);
routersToUnenroll.push(canonicalRouter);
}
}
}
if (domainsToUnenroll.length === 0) {
return [];
}
return [
{
chainId: this.chainId,
annotation: `Unenrolling ${domainsToUnenroll.length} CrossCollateralRouter routers on ${this.args.addresses.deployedTokenRoute} on ${this.chainName}`,
to: this.args.addresses.deployedTokenRoute,
data: CrossCollateralRouter__factory.createInterface().encodeFunctionData('unenrollCrossCollateralRouters', [domainsToUnenroll, routersToUnenroll]),
},
];
}
toCanonicalRouterId(router) {
const lower = router.toLowerCase();
if (isAddressEvm(lower)) {
return addressToBytes32(lower);
}
return lower;
}
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]),
};
});
});
}
createAddRemoteOutputAssetsTxs(actualConfig, expectedConfig) {
if (!isEverclearTokenBridgeConfig(expectedConfig) ||
!isEverclearTokenBridgeConfig(actualConfig)) {
return [];
}
const actualOutputAssets = resolveRouterMapConfig(this.multiProvider, actualConfig.outputAssets);
const expectedOutputAssets = resolveRouterMapConfig(this.multiProvider, expectedConfig.outputAssets);
const outputAssetsToAdd = objDiff(expectedOutputAssets, actualOutputAssets, (address, address2) => addressToBytes32(address) === addressToBytes32(address2));
if (isObjEmpty(outputAssetsToAdd)) {
return [];
}
const assets = Object.entries(outputAssetsToAdd).map(([domainId, outputAsset]) => ({
destination: parseInt(domainId),
outputAsset: addressToBytes32(outputAsset),
}));
return [
{
chainId: this.multiProvider.getEvmChainId(this.chainId),
to: this.args.addresses.deployedTokenRoute,
annotation: `Adding "${Object.keys(assets)}" output assets for token "${this.args.addresses.deployedTokenRoute}" on chain "${this.chainName}"`,
data: EverclearTokenBridge__factory.createInterface().encodeFunctionData('setOutputAssetsBatch((uint32,bytes32)[])', [assets]),
},
];
}
createRemoveRemoteOutputAssetsTxs(actualConfig, expectedConfig) {
if (!isEverclearTokenBridgeConfig(expectedConfig) ||
!isEverclearTokenBridgeConfig(actualConfig)) {
return [];
}
const actualOutputAssets = resolveRouterMapConfig(this.multiProvider, actualConfig.outputAssets);
const expectedOutputAssets = resolveRouterMapConfig(this.multiProvider, expectedConfig.outputAssets);
const outputAssetsToRemove = Array.from(difference(new Set(objKeys(actualOutputAssets)), new Set(objKeys(expectedOutputAssets))));
if (outputAssetsToRemove.length === 0) {
return [];
}
const assets = outputAssetsToRemove.map((domainId) => ({
destination: domainId,
outputAsset: ZERO_ADDRESS_HEX_32,
}));
return [
{
chainId: this.multiProvider.getEvmChainId(this.chainId),
to: this.args.addresses.deployedTokenRoute,
annotation: `Removing "${outputAssetsToRemove}" output assets from token "${this.args.addresses.deployedTokenRoute}" on chain "${this.chainName}"`,
data: EverclearTokenBridge__factory.createInterface().encodeFunctionData('setOutputAssetsBatch((uint32,bytes32)[])', [assets]),
},
];
}
createUpdateEverclearFeeParamsTxs(actualConfig, expectedConfig) {
if (!isEverclearTokenBridgeConfig(expectedConfig) ||
!isEverclearTokenBridgeConfig(actualConfig)) {
return [];
}
if (deepEquals(expectedConfig.everclearFeeParams, actualConfig.everclearFeeParams)) {
return [];
}
const resolvedEverclearExpectedFeeConfig = resolveRouterMapConfig(this.multiProvider, expectedConfig.everclearFeeParams);
const resolvedActualEverclearFeeConfig = resolveRouterMapConfig(this.multiProvider, actualConfig.everclearFeeParams);
const feesToSet = objFilter(resolvedEverclearExpectedFeeConfig, (domainId, currentDomainConfig) => {
return (isNullish(resolvedActualEverclearFeeConfig[Number(domainId)]) ||
!deepEquals(currentDomainConfig, resolvedActualEverclearFeeConfig[Number(domainId)]));
});
return Object.entries(feesToSet).map(([domainId, feeConfig]) => {
const { deadline, fee, signature } = feeConfig;
// Deadline is in seconds
const humanReadableDeadline = new Date(deadline * 1000).toISOString();
return {
annotation: `Setting Everclear fee params with deadline "${humanReadableDeadline}" for domain "${domainId}" on token "${this.args.addresses.deployedTokenRoute}" and chain "${this.chainName}"`,
chainId: this.multiProvider.getEvmChainId(this.chainName),
to: this.args.addresses.deployedTokenRoute,
data: EverclearTokenBridge__factory.createInterface().encodeFunctionData('setFeeParams', [domainId, fee, deadline, signature]),
};
});
}
createRemoveEverclearFeeParamsTxs(actualConfig, expectedConfig) {
if (!isEverclearTokenBridgeConfig(expectedConfig) ||
!isEverclearTokenBridgeConfig(actualConfig)) {
return [];
}
const resolvedEverclearExpectedFeeConfig = resolveRouterMapConfig(this.multiProvider, expectedConfig.everclearFeeParams);
const resolvedActualEverclearFeeConfig = resolveRouterMapConfig(this.multiProvider, actualConfig.everclearFeeParams);
const outputAssetsToRemove = Array.from(difference(new Set(objKeys(resolvedActualEverclearFeeConfig)), new Set(objKeys(resolvedEverclearExpectedFeeConfig))));
if (outputAssetsToRemove.length === 0) {
return [];
}
return outputAssetsToRemove.map((domainId) => {
return {
annotation: `Removing Everclear fee params for domain "${domainId}" on token "${this.args.addresses.deployedTokenRoute}" and chain "${this.chainName}"`,
chainId: this.multiProvider.getEvmChainId(this.chainName),
to: this.args.addresses.deployedTokenRoute,
data: EverclearTokenBridge__factory.createInterface().encodeFunctionData('setFeeParams',
// Setting default values to reset the config for the provided domain
[domainId, 0, 0, '0x']),
};
});
}
/**
* 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) {
// OFT contracts don't have GasRouter interface — no destination gas config
if (isOftTokenConfig(expectedConfig)) {
return [];
}
const updateTransactions = [];
if (!expectedConfig.destinationGas) {
return [];
}
assert(actualConfig.destinationGas, 'actualDestinationGas is undefined');
assert(expectedConfig.destinationGas, 'expectedDestinationGas is undefined');
// Only set gas for domains that will have routers enrolled after the update.
// For CrossCollateralRouter configs, also include domains from crossCollateralRouters.
const resolvedExpectedRemoteRouters = resolveRouterMapConfig(this.multiProvider, expectedConfig.remoteRouters ?? {});
const expectedRouterDomains = new Set(Object.keys(resolvedExpectedRemoteRouters).map(Number));
// Include MC-enrolled router domains
if (isCrossCollateralTokenConfig(expectedConfig) &&
expectedConfig.crossCollateralRouters) {
const localDomain = this.multiProvider.getDomainId(this.chainName);
const resolvedEnrolled = resolveRouterMapConfig(this.multiProvider, expectedConfig.crossCollateralRouters);
for (const domain of Object.keys(resolvedEnrolled).map(Number)) {
if (domain === localDomain)
continue;
expectedRouterDomains.add(domain);
}
}
if (expectedRouterDomains.size === 0 &&
Object.keys(expectedConfig.destinationGas).length > 0) {
throw new Error(`destinationGas is set but remoteRouters and crossCollateralRouters are empty. ` +
`Cannot configure gas for domains without corresponding router enrollments.`);
}
const actualDestinationGas = resolveRouterMapConfig(this.multiProvider, actualConfig.destinationGas);
const expectedDestinationGas = resolveRouterMapConfig(this.multiProvider, expectedConfig.destinationGas);
// Filter to only domains that will have routers enrolled
const filteredExpectedGas = Object.fromEntries(Object.entries(expectedDestinationGas).filter(([domain]) => expectedRouterDomains.has(Number(domain))));
// Filter actual gas to the same domains for comparison
const filteredActualGas = Object.fromEntries(Object.entries(actualDestinationGas).filter(([domain]) => expectedRouterDomains.has(Number(domain))));
if (!deepEquals(filteredActualGas, filteredExpectedGas)) {
// Convert { 1: 2, 2: 3, ... } to [{ 1: 2 }, { 2: 3 }]
const gasRouterConfigs = [];
objMap(filteredExpectedGas, (domain, gas) => {
gasRouterConfigs.push({
domain,
gas,
});
});
const contractToUpdate = GasRouter__factory.connect(this.args.addresses.deployedTokenRoute, this.multiProvider.getProvider(this.domainId));
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) {
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 (!eqAddress(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) {
return this.createHookAndPredicateUpdateTxs(actualConfig, expectedConfig);
}
/**
* Deploys hook updates and predicate wrapper together so the post-update hook address
* is available to deployAndConfigure without a stale on-chain read.
*/
async createHookAndPredicateUpdateTxs(actualConfig, expectedConfig) {
let hookTransactions = [];
let newHookAddress;
// Explicit type annotation narrows away the undefined that TypeScript infers
// from the RouterConfig & DerivedMailboxClientConfig intersection.
const actualHook = actualConfig.hook;
// Predicate removal: on-chain wrapper exists but expected config omits it.
// When the user provides an explicit hook that differs from the underlying hook
// (predicate stripped), the hook-diff path generates the setHook to the new hook.
// Otherwise (no hook, or a stale aggregation from a warp read), the
// needsPredicateRemoval block below clears the hook to zero so the router uses the
// mailbox default — the user who removed predicateWrapper without supplying a
// replacement hook wants the default behavior restored, not the underlying sub-hook
// silently preserved.
const needsPredicateRemoval = actualConfig.predicateWrapper != null && !expectedConfig.predicateWrapper;
// Treat a zero-address hook the same as "no explicit hook": expandWarpDeployConfig
// sets hook: zeroAddress as a default when the user config omits the hook field.
// EvmHookModule.update(zeroAddress) returns [] early without updating deployedHook,
// so a zero-address target produces no setHook tx and the predicate removal branch
// would never be reached. Exclude zero addresses so needsPredicateRemoval can fire.
if (expectedConfig.hook &&
(typeof expectedConfig.hook !== 'string' ||
!isZeroishAddress(expectedConfig.hook))) {
const proxyAdminAddress = expectedConfig.proxyAdmin?.address ?? actualConfig.proxyAdmin?.address;
assert(proxyAdminAddress, 'ProxyAdmin address is undefined');
// The reader leaves the PREDICATE sub-hook inside actualConfig.hook
// (e.g. Agg([Predicate, IGP])). When expectedConfig is derived from
// actualConfig (e.g. during the enrollment step after initial deploy, OR
// during a read→edit→apply round-trip where the operator removed predicateWrapper
// but kept the hook field unchanged), expectedConfig.hook carries the same
// aggregation. Strip the predicate from BOTH sides so the hook diff sees the bare
// hook (e.g. IGP) on both sides and doesn't generate a spurious setHook.
// The needsPredicateRemoval block below then fires to clear the hook to zero.
const shouldStripHookForComparison = !!expectedConfig.predicateWrapper || needsPredicateRemoval;
const actualHookForComparison = shouldStripHookForComparison
? stripPredicateSubHook(actualHook)
: actualHook;
const expectedHookForComparison = shouldStripHookForComparison && expectedConfig.hook
? stripPredicateSubHook(expectedConfig.hook)
: expectedConfig.hook;
const result = await getEvmHookUpdateTransactions(this.args.addresses.deployedTokenRoute, {
actualConfig: actualHookForComparison,
expectedConfig: expectedHookForComparison,
ccipContractCache: this.ccipContractCache,
contractVerifier: this.contractVerifier,
evmChainName: this.chainName,
hookAndIsmFactories: extractIsmAndHookFactoryAddresses(this.args.addresses),
setHookFunctionCallEncoder: (addr) => MailboxClient__factory.createInterface().encodeFunctionData('setHook', [addr]),
logger: this.logger,
mailbox: actualConfig.mailbox,
multiProvider: this.multiProvider,
proxyAdminAddress,
rateLimitedSender: this.args.addresses.deployedTokenRoute,
});
hookTransactions = result.transactions;
newHookAddress = result.newHookAddress;
}
// Predicate removal when no new hook was deployed: clear the custom hook entirely.
// This fires whether or not expectedConfig.hook was provided — it handles both the
// "no hook field" case and the round-trip hazard where the operator removed
// predicateWrapper but left an unchanged hook field (still containing the aggregation).
if (needsPredicateRemoval && !newHookAddress) {
const currentAddress = typeof actualHook === 'string' ? actualHook : actualHook.address;
if (!isZeroishAddress(currentAddress)) {
this.logger.debug({ chain: this.chainName }, 'Removing predicate wrapper: generating setHook(zero) to clear custom hook');
hookTransactions.push({
annotation: 'Remove predicate wrapper: clear custom hook (router will use mailbox default)',
chainId: this.chainId,
to: this.args.addresses.deployedTokenRoute,
data: MailboxClient__factory.createInterface().encodeFunctionData('setHook', [constants.AddressZero]),
});
}
}
const { transactions: predicateTransactions, deploysNewWrapper } = await this.createPredicateWrapperUpdateTxs(actualConfig, expectedConfig, newHookAddress);
// When predicate wrapper is being deployed, its setHook(aggregation) sets the final
// router hook and already incorporates newHookAddress inside the aggregation.
// Drop the intermediate setHook(newHookAddress) from hookTransactions to avoid a
// redundant write that would be immediately overwritten.
//
// IMPORTANT: only drop when a NEW wrapper is being deployed. The ownership-only
// path (deploysNewWrapper=false) must not suppress the hook update even though
// predicateTransactions is non-empty.
const effectiveHookTransactions = hookTransactions.length > 0 && deploysNewWrapper
? hookTransactions.filter((tx) => !(tx.to &&
eqAddress(tx.to, this.args.addresses.deployedTokenRoute) &&
tx.data?.startsWith(MailboxClient__factory.createInterface().getSighash('setHook'))))
: hookTransactions;
return [...effectiveHookTransactions, ...predicateTransactions];
}
/**
* Searches the current on-chain hook tree for a PredicateRouterWrapper that
* matches by registry and policyId. Returns the wrapper address and its current
* on-chain owner when found, undefined otherwise.
*
* Uses unbounded recursion into aggregation hooks (consistent with
* EvmTokenAdapter.findPredicateWrapperInHook and EvmWarpRouteReader.findPredicateAddressInHook).
*/
async findDeployedPredicateWrapper(actualConfig, expectedPredicateConfig) {
const hookAddress = derivedHookAddress(actualConfig);
if (!hookAddress || isZeroishAddress(hookAddress))
return undefined;
try {
const provider = this.multiProvider.getProvider(this.domainId);
return await this.searchPredicateInHook(hookAddress, provider, expectedPredicateConfig);
}
catch (error) {
this.logger.debug({ chain: this.chainName, error }, 'Error checking predicate wrapper deployment');
}
return undefined;
}
/**
* Recursively searches a hook tree for a matching PredicateRouterWrapper.
* Descends into StaticAggregationHook sub-hooks without depth limit.
*/
async searchPredicateInHook(hookAddr, provider, expectedPredicateConfig) {
const match = await this.matchPredicateWrapper(hookAddr, provider, expectedPredicateConfig);
if (match)
return match;
let subHooks;
try {
subHooks = await StaticAggregationHook__factory.connect(hookAddr, provider).hooks('0x');
}
catch {
// Any call failure means hookAddr is not a StaticAggregationHook.
// HyperlaneSmartProvider wraps CALL_EXCEPTION as "Invalid response from provider"
// with code: undefined, so checking error.code is insufficient.
return undefined;
}
for (const subHook of subHooks) {
const found = await this.searchPredicateInHook(subHook, provider, expectedPredicateConfig);
if (found)
return found;
}
return undefined;
}
/**
* Checks whether a single hook address is a PredicateRouterWrapper matching
* the warp route and expected config. Returns the match or undefined.
*/
async matchPredicateWrapper(hookAddr, provider, expectedPredicateConfig) {
try {
const predicateWrapper = PredicateRouterWrapper__factory.connect(hookAddr, provider);
// Verify identity: warpRoute + hookType confirm it's a PredicateRouterWrapper
// for this route. Then compare registry + policyId so config rotations
// (e.g. changing compliance policy) trigger a redeploy rather than silently no-op.
const [warpRoute, hookType, onchainRegistry, onchainPolicyId, onchainOwner,] = await Promise.all([
predicateWrapper.warpRoute(),
predicateWrapper.hookType(),
predicateWrapper.getRegistry(),
predicateWrapper.getPolicyID(),
predicateWrapper.owner(),
]);
if (eqAddress(warpRoute, this.args.addresses.deployedTokenRoute) &&
hookType === OnchainHookType.PREDICATE_ROUTER_WRAPPER &&
eqAddress(onchainRegistry, expectedPredicateConfig.predicateRegistry) &&
onchainPolicyId === expectedPredicateConfig.policyId) {
return { address: hookAddr, onchainOwner };
}
}
catch {
// Any call failure means hookAddr is not a PredicateRouterWrapper.
// HyperlaneSmartProvider wraps CALL_EXCEPTION as "Invalid response from provider"
// with code: undefined, so checking error.code === 'CALL_EXCEPTION' is insufficient.
return undefined;
}
return undefined;
}
/**
* Check if predicate wrapper is already deployed with fully matching config
* (registry, policyId, and owner).
*
* @param actualConfig - The on-chain router configuration.
* @param expectedPredicateConfig - The expected predicate wrapper configuration.
* @returns True if wrapper is deployed with all fields matching, false otherwise.
*/
async isPredicateWrapperDeployed(actualConfig, expectedPredicateConfig) {
const found = await this.findDeployedPredicateWrapper(actualConfig, expectedPredicateConfig);
return (found !== undefined &&
eqAddress(found.onchainOwner, expectedPredicateConfig.owner));
}
/**
* Create transactions to deploy predicate wrapper and update hook.
*
* @param actualConfig - The on-chain router configuration.
* @param expectedConfig - The expected token router configuration.
* @returns transactions to execute and whether a new wrapper is being deployed.
* deploysNewWrapper=true means the predicate emits its own setHook(aggregation)
* that supersedes any hook update in the same batch.
*/
async createPredicateWrapperUpdateTxs(actualConfig, expectedConfig, pendingHookAddress) {
// Only proceed if expectedConfig has predicateWrapper
if (!('predicateWrapper' in expectedConfig) ||
!expectedConfig.predicateWrapper) {
return { transactions: [], deploysNewWrapper: false };
}
const predicateWrapperConfig = PredicateWrapperConfigSchema.parse(expectedConfig.predicateWrapper);
// Check if a wrapper matching by registry+policyId already exists on-chain.
// If so, only a transferOwnership tx is needed (not a full redeploy).
const existingWrapper = await this.findDeployedPredicateWrapper(actualConfig, predicateWrapperConfig);
if (existingWrapper) {
if (eqAddress(existingWrapper.onchainOwner, predicateWrapperConfig.owner)) {
this.logger.debug({ chain: this.chainName }, 'Predicate wrapper already deployed with matching config, skipping');
return { transactions: [], deploysNewWrapper: false };
}
// Owner changed — generate a transferOwnership tx without redeploying.
this.logger.debug({ chain: this.chainName, wrapper: existingWrapper.address }, 'Predicate wrapper owner changed, generating transferOwnership transaction');
const transferOwnershipTx = await PredicateRouterWrapper__factory.connect(existingWrapper.address, this.multiProvider.getProvider(this.chainName)).populateTransaction.transferOwnership(predicateWrapperConfig.owner);
return {
transactions: [
{
...transferOwnershipTx,
chainId: this.chainId,
annotation: `Transferring predicate wrapper ownership to ${predicateWrapperConfig.owner}`,
},
],
deploysNewWrapper: false,
};
}
const staticAggregationHookFactory = this.args.addresses.staticAggregationHookFactory;
if (!staticAggregationHookFactory) {
throw new Error(`staticAggregationHookFactory not found for ${this.chainName}. Ensure proxy factories are deployed.`);
}
const signer = this.multiProvider.getSigner(this.chainName);
const factory = StaticAggregationHookFactory__factory.connect(staticAggregationHookFactory, signer);
const predicateDeployer = new PredicateWrapperDeploy