@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
446 lines • 21 kB
JavaScript
import { ethers } from 'ethers';
import { AbstractCcipReadIsm__factory, AbstractRoutingIsm__factory, AmountRoutingIsm__factory, ArbL2ToL1Ism__factory, CCIPIsm__factory, DefaultFallbackRoutingIsm__factory, IInterchainSecurityModule__factory, IMultisigIsm__factory, IOutbox__factory, InterchainAccountRouter__factory, OPStackIsm__factory, Ownable__factory, PausableIsm__factory, RateLimitedIsm__factory, StaticAggregationIsm__factory, TrustedRelayerIsm__factory, } from '@hyperlane-xyz/core';
import { assert, concurrentMap, getLogLevel, objMap, promiseObjAll, rootLogger, } from '@hyperlane-xyz/utils';
import { getChainNameFromCCIPSelector } from '../ccip/utils.js';
import { DEFAULT_CONTRACT_READ_CONCURRENCY } from '../consts/concurrency.js';
import { ChainTechnicalStack } from '../metadata/chainMetadataTypes.js';
import { HyperlaneReader } from '../utils/HyperlaneReader.js';
import { contractHasString, isMissingSelectorCallException, throwIfNotMissingSelector, } from '../utils/contract.js';
import { IsmType, ModuleType, } from './types.js';
const INCREMENTAL_REVERT_STRING = 'IncrementalDomainRoutingIsm: removal not supported';
// ISM types that cannot be deployed by HyperlaneIsmFactory — preserve as address strings.
const NON_REDEPLOYABLE_ISM_TYPES = new Set([
IsmType.OFFCHAIN_LOOKUP,
IsmType.INTERCHAIN_ACCOUNT_ROUTING,
]);
export class EvmIsmReader extends HyperlaneReader {
multiProvider;
chain;
concurrency;
messageContext;
logger = rootLogger.child({ module: 'EvmIsmReader' });
isZkSyncChain;
constructor(multiProvider, chain, concurrency = multiProvider.tryGetRpcConcurrency(chain) ?? DEFAULT_CONTRACT_READ_CONCURRENCY, messageContext) {
super(multiProvider, chain);
this.multiProvider = multiProvider;
this.chain = chain;
this.concurrency = concurrency;
this.messageContext = messageContext;
// So we can distinguish between Storage/Static ISMs
const chainTechnicalStack = this.multiProvider.getChainMetadata(this.chain).technicalStack;
this.isZkSyncChain = chainTechnicalStack === ChainTechnicalStack.ZkSync;
}
async deriveIsmConfigFromAddress(address) {
let moduleType = undefined;
let derivedIsmConfig;
try {
const ism = IInterchainSecurityModule__factory.connect(address, this.provider);
this.logger.debug('Deriving IsmConfig:', { address });
// Temporarily turn off SmartProvider logging
// Provider errors are expected because deriving will call methods that may not exist in the Bytecode
this.setSmartProviderLogLevel('silent');
moduleType = await ism.moduleType();
switch (moduleType) {
case ModuleType.UNUSED:
throw new Error('UNUSED does not have a corresponding IsmType');
case ModuleType.ROUTING:
// IsmType is either ROUTING or FALLBACK_ROUTING, but that's determined inside deriveRoutingConfig
derivedIsmConfig = await this.deriveRoutingConfig(address);
break;
case ModuleType.AGGREGATION:
derivedIsmConfig = await this.deriveAggregationConfig(address);
break;
case ModuleType.LEGACY_MULTISIG:
throw new Error('LEGACY_MULTISIG is deprecated and not supported');
case ModuleType.MERKLE_ROOT_MULTISIG:
case ModuleType.MESSAGE_ID_MULTISIG:
derivedIsmConfig = await this.deriveMultisigConfig(address);
break;
case ModuleType.NULL:
derivedIsmConfig = await this.deriveNullConfig(address);
break;
case ModuleType.CCIP_READ:
derivedIsmConfig = await this.deriveOffchainLookupConfig(address);
break;
case ModuleType.ARB_L2_TO_L1:
return this.deriveArbL2ToL1Config(address);
default:
throw new Error(`Unknown ISM ModuleType: ${moduleType}`);
}
}
catch (e) {
const errorMessage = `Failed to derive ISM module type ${moduleType} on ${this.chain} (${address}) :\n\t${e}`;
this.logger.debug(errorMessage);
throw new Error(errorMessage);
}
finally {
this.setSmartProviderLogLevel(getLogLevel()); // returns to original level defined by rootLogger
}
return derivedIsmConfig;
}
async deriveOffchainLookupConfig(address) {
const ism = AbstractCcipReadIsm__factory.connect(address, this.provider);
this.assertModuleType(await ism.moduleType(), ModuleType.CCIP_READ);
const [urls, owner] = await Promise.all([ism.urls(), ism.owner()]);
return {
address,
type: IsmType.OFFCHAIN_LOOKUP,
urls,
owner,
};
}
// expands ISM configs that are set as addresses by deriving the config
// from the on-chain deployment
async deriveIsmConfig(config) {
if (typeof config === 'string')
return this.deriveIsmConfigFromAddress(config);
// Extend the inner isms
switch (config.type) {
case IsmType.FALLBACK_ROUTING:
case IsmType.ROUTING:
config.domains = await promiseObjAll(objMap(config.domains, async (_, ism) => {
const derived = await this.deriveIsmConfig(ism);
return this.preserveUnredeployableIsm(ism, derived);
}));
break;
case IsmType.AGGREGATION:
case IsmType.STORAGE_AGGREGATION:
config.modules = await Promise.all(config.modules.map(async (ism) => {
const derived = await this.deriveIsmConfig(ism);
return this.preserveUnredeployableIsm(ism, derived);
}));
break;
case IsmType.AMOUNT_ROUTING: {
const lowerOrig = config.lowerIsm;
const upperOrig = config.upperIsm;
const [lowerDerived, upperDerived] = await Promise.all([
this.deriveIsmConfig(lowerOrig),
this.deriveIsmConfig(upperOrig),
]);
config.lowerIsm = this.preserveUnredeployableIsm(lowerOrig, lowerDerived);
config.upperIsm = this.preserveUnredeployableIsm(upperOrig, upperDerived);
break;
}
}
return config;
}
// Returns the original IsmConfig for non-redeployable ISM types (e.g. OFFCHAIN_LOOKUP,
// INTERCHAIN_ACCOUNT_ROUTING) so normalizeConfig and deploy() handle them correctly.
// The original is typically a string address that survives normalizeConfig intact and
// reaches deploy()'s string branch; an object config is also preserved via derived.address.
preserveUnredeployableIsm(original, derived) {
if (!NON_REDEPLOYABLE_ISM_TYPES.has(derived.type))
return derived;
if (typeof original === 'string')
return original;
return derived.address;
}
async deriveRoutingConfig(address) {
const abstractRoutingIsm = AbstractRoutingIsm__factory.connect(address, this.provider);
this.assertModuleType(await abstractRoutingIsm.moduleType(), ModuleType.ROUTING);
// OPTIMIZATION: When we have messageContext, we only need to derive
// the specific ISM that will verify this message, not the full routing table.
// Just call route(message) and derive that single ISM directly.
if (this.messageContext) {
const routedIsmAddress = await abstractRoutingIsm.route(this.messageContext.message);
return this.deriveIsmConfig(routedIsmAddress);
}
const isIca = await this.isIcaRouter(address);
let owner;
const ownableIsm = Ownable__factory.connect(address, this.provider);
try {
owner = await ownableIsm.owner();
}
catch (error) {
throwIfNotMissingSelector(error);
this.logger.debug('Error accessing owner property, implying that this is not a DefaultFallbackRoutingIsm.', address);
}
// If the current ISM does not have an owner then it is an Amount Router
if (!owner) {
if (isIca) {
return {
type: IsmType.INTERCHAIN_ACCOUNT_ROUTING,
isms: {},
address,
owner: ethers.constants.AddressZero,
};
}
return this.deriveNonOwnableRoutingConfig(address);
}
const defaultFallbackIsmInstance = DefaultFallbackRoutingIsm__factory.connect(address, this.provider);
const domainIds = await defaultFallbackIsmInstance.domains();
// Detect ICA routing ISM (full or minimal router)
if (isIca) {
const icaRouter = InterchainAccountRouter__factory.connect(address, this.provider);
return {
type: IsmType.INTERCHAIN_ACCOUNT_ROUTING,
isms: await this.deriveRemoteIsmConfigs(domainIds, abstractRoutingIsm, (domain) => icaRouter.isms(domain),
// The isms here are deployed on remote chains and can't be derived
false),
address,
owner,
};
}
const domains = await this.deriveRemoteIsmConfigs(domainIds, abstractRoutingIsm, (domain) => defaultFallbackIsmInstance.module(domain), true);
// Fallback routing ISM extends from MailboxClient, default routing
let ismType = IsmType.FALLBACK_ROUTING;
try {
await defaultFallbackIsmInstance.mailbox();
}
catch (error) {
throwIfNotMissingSelector(error);
ismType = IsmType.ROUTING;
this.logger.debug('Error accessing mailbox property, implying this is not a fallback routing ISM.', address);
}
// Check if it's an incremental routing ISM by looking for the unique error message in bytecode
if (ismType === IsmType.ROUTING) {
if (await contractHasString(this.provider, address, INCREMENTAL_REVERT_STRING)) {
ismType = IsmType.INCREMENTAL_ROUTING;
this.logger.debug({ address }, 'Detected incremental routing ISM');
}
}
return {
owner,
address,
type: ismType,
domains,
};
}
/**
* Detects whether the contract at `address` is an ICA router
* by probing for CCIP_READ_ISM() (full router) or bytecodeHash() (minimal router).
*/
async isIcaRouter(address) {
const icaInstance = InterchainAccountRouter__factory.connect(address, this.provider);
try {
await icaInstance.CCIP_READ_ISM();
return true;
}
catch (error) {
throwIfNotMissingSelector(error);
try {
await icaInstance.bytecodeHash();
return true;
}
catch (innerError) {
throwIfNotMissingSelector(innerError);
this.logger.debug('Not an ICA router (no CCIP_READ_ISM or bytecodeHash).', address);
return false;
}
}
}
async deriveRemoteIsmConfigs(domainIds, contractInstance, addressDeriveFunc, deriveConfig) {
const res = await concurrentMap(this.concurrency, domainIds, async (domainId) => {
const chainName = this.multiProvider.tryGetChainName(domainId.toNumber());
if (!chainName) {
this.logger.warn(`Unknown domain ID ${domainId.toString()}, skipping domain configuration`);
return;
}
const moduleAddress = this.messageContext
? await contractInstance.route(this.messageContext.message)
: await addressDeriveFunc(domainId);
if (deriveConfig) {
const derived = await this.deriveIsmConfigFromAddress(moduleAddress);
return [
chainName,
this.preserveUnredeployableIsm(moduleAddress, derived),
];
}
return [chainName, moduleAddress];
});
return Object.fromEntries(res.filter((curr) => curr));
}
async deriveNonOwnableRoutingConfig(address) {
const ism = AmountRoutingIsm__factory.connect(address, this.provider);
let lowerIsm;
let upperIsm;
let threshold;
try {
[lowerIsm, upperIsm, threshold] = await Promise.all([
ism.lower(),
ism.upper(),
ism.threshold(),
]);
}
catch (error) {
throwIfNotMissingSelector(error);
// If we fail to access AmountRoutingIsm properties, this is likely a legacy InterchainAccountIsm
this.logger.debug('Error accessing AmountRoutingIsm properties, treating as legacy InterchainAccountIsm.', address);
// return a basic ICA routing config for legacy contracts
return {
type: IsmType.INTERCHAIN_ACCOUNT_ROUTING,
isms: {},
address,
owner: ethers.constants.AddressZero,
};
}
const [lowerDerived, upperDerived] = await Promise.all([
this.deriveIsmConfigFromAddress(lowerIsm),
this.deriveIsmConfigFromAddress(upperIsm),
]);
return {
type: IsmType.AMOUNT_ROUTING,
address,
lowerIsm: this.preserveUnredeployableIsm(lowerIsm, lowerDerived),
upperIsm: this.preserveUnredeployableIsm(upperIsm, upperDerived),
threshold: threshold.toNumber(),
};
}
async deriveAggregationConfig(address) {
const ism = StaticAggregationIsm__factory.connect(address, this.provider);
this.assertModuleType(await ism.moduleType(), ModuleType.AGGREGATION);
const [modules, threshold] = await ism.modulesAndThreshold(ethers.constants.AddressZero);
const ismConfigs = await concurrentMap(this.concurrency, modules, async (module) => {
const derived = await this.deriveIsmConfigFromAddress(module);
return this.preserveUnredeployableIsm(module, derived);
});
// If it's a zkSync chain, it must be a StorageAggregationIsm
const ismType = this.isZkSyncChain
? IsmType.STORAGE_AGGREGATION
: IsmType.AGGREGATION;
return {
address,
type: ismType,
modules: ismConfigs,
threshold,
};
}
async deriveMultisigConfig(address) {
const ism = IMultisigIsm__factory.connect(address, this.provider);
const moduleType = await ism.moduleType();
assert(moduleType === ModuleType.MERKLE_ROOT_MULTISIG ||
moduleType === ModuleType.MESSAGE_ID_MULTISIG, `expected module type to be ${ModuleType.MERKLE_ROOT_MULTISIG} or ${ModuleType.MESSAGE_ID_MULTISIG}, got ${moduleType}`);
let ismType = moduleType === ModuleType.MERKLE_ROOT_MULTISIG
? IsmType.MERKLE_ROOT_MULTISIG
: IsmType.MESSAGE_ID_MULTISIG;
// If it's a zkSync chain, it must be a StorageMultisigIsm
if (this.isZkSyncChain) {
ismType =
moduleType === ModuleType.MERKLE_ROOT_MULTISIG
? IsmType.STORAGE_MERKLE_ROOT_MULTISIG
: IsmType.STORAGE_MESSAGE_ID_MULTISIG;
}
const [validators, threshold] = await ism.validatorsAndThreshold(ethers.constants.AddressZero);
return {
address,
type: ismType,
validators,
threshold,
};
}
async deriveNullConfig(address) {
const ism = IInterchainSecurityModule__factory.connect(address, this.provider);
this.assertModuleType(await ism.moduleType(), ModuleType.NULL);
// if it has trustedRelayer() property --> TRUSTED_RELAYER
const trustedRelayerIsm = TrustedRelayerIsm__factory.connect(address, this.provider);
try {
const relayer = await trustedRelayerIsm.trustedRelayer();
return {
address,
relayer,
type: IsmType.TRUSTED_RELAYER,
};
}
catch (error) {
throwIfNotMissingSelector(error);
this.logger.debug('Error accessing "trustedRelayer" property, implying this is not a Trusted Relayer ISM.', address);
}
// if it has paused() property --> PAUSABLE
const pausableIsm = PausableIsm__factory.connect(address, this.provider);
const [pausedResult, ownerResult] = await Promise.allSettled([
pausableIsm.paused(),
pausableIsm.owner(),
]);
const unexpectedError = [pausedResult, ownerResult].find((result) => result.status === 'rejected' &&
!isMissingSelectorCallException(result.reason));
if (unexpectedError?.status === 'rejected') {
throw unexpectedError.reason;
}
if (pausedResult.status === 'fulfilled' &&
ownerResult.status === 'fulfilled') {
return {
address,
owner: ownerResult.value,
type: IsmType.PAUSABLE,
paused: pausedResult.value,
};
}
else {
this.logger.debug('Error accessing "paused" property, implying this is not a Pausable ISM.', address);
}
// if it has ccipOrigin property --> CCIP
const ccipIsm = CCIPIsm__factory.connect(address, this.provider);
try {
const ccipOrigin = await ccipIsm.ccipOrigin();
const originChain = getChainNameFromCCIPSelector(ccipOrigin.toString());
if (!originChain) {
throw new Error('Unknown CCIP origin chain');
}
return {
address,
type: IsmType.CCIP,
originChain,
};
}
catch (error) {
throwIfNotMissingSelector(error);
this.logger.debug('Error accessing "ccipOrigin" property, implying this is not a CCIP ISM.', address);
}
// if it has VERIFIED_MASK_INDEX, it's AbstractMessageIdAuthorizedIsm which means OPStackIsm
const opStackIsm = OPStackIsm__factory.connect(address, this.provider);
try {
await opStackIsm.VERIFIED_MASK_INDEX();
return {
address,
type: IsmType.OP_STACK,
origin: address,
nativeBridge: '', // no way to extract native bridge from the ism
};
}
catch (error) {
throwIfNotMissingSelector(error);
this.logger.debug('Error accessing "VERIFIED_MASK_INDEX" property, implying this is not an OP Stack ISM.', address);
}
// Detect RateLimitedIsm by probing for its unique recipient() selector.
// Safe today because no other NULL-type ISM in this repo exposes recipient().
// TODO: replace with bytecode or unique-selector matching if custom NULL ISMs
// with the same interface are ever introduced.
const rateLimitedIsm = RateLimitedIsm__factory.connect(address, this.provider);
try {
const recipient = await rateLimitedIsm.recipient();
const maxCapacity = (await rateLimitedIsm.maxCapacity()).toString();
const owner = await rateLimitedIsm.owner();
return {
address,
type: IsmType.RATE_LIMITED,
recipient,
maxCapacity,
owner,
};
}
catch (error) {
throwIfNotMissingSelector(error);
this.logger.debug('Error accessing "recipient" property, implying this is not a Rate Limited ISM.', address);
}
// no specific properties, must be Test ISM
return {
address,
type: IsmType.TEST_ISM,
};
}
async deriveArbL2ToL1Config(address) {
const ism = ArbL2ToL1Ism__factory.connect(address, this.provider);
const outbox = await ism.arbOutbox();
const outboxContract = IOutbox__factory.connect(outbox, this.provider);
const bridge = await outboxContract.bridge();
return {
address,
type: IsmType.ARB_L2_TO_L1,
bridge,
};
}
assertModuleType(moduleType, expectedModuleType) {
assert(moduleType === expectedModuleType, `expected module type to be ${expectedModuleType}, got ${moduleType}`);
}
}
//# sourceMappingURL=EvmIsmReader.js.map