UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

262 lines 14.5 kB
import { BigNumber, ethers } from 'ethers'; import { InterchainAccountRouter__factory, } from '@hyperlane-xyz/core'; import { addBufferToGasLimit, addressToBytes32, arrayToObject, bytes32ToAddress, eqAddress, formatStandardHookMetadata, isZeroishAddress, objFilter, objMap, parseStandardHookMetadata, promiseObjAll, } from '@hyperlane-xyz/utils'; import { appFromAddressesMapHelper } from '../../contracts/contracts.js'; import { RouterApp } from '../../router/RouterApps.js'; import { estimateCallGas, estimateHandleGasForRecipient, } from '../../utils/gas.js'; import { interchainAccountFactories, } from './contracts.js'; const IGP_DEFAULT_GAS = BigNumber.from(50_000); const ICA_OVERHEAD = BigNumber.from(50_000); const PER_CALL_OVERHEAD = BigNumber.from(5_000); const ICA_HANDLE_GAS_FALLBACK = BigNumber.from(200_000); export class InterchainAccount extends RouterApp { knownAccounts; constructor(contractsMap, multiProvider) { super(contractsMap, multiProvider); this.knownAccounts = {}; } async remoteChains(chainName) { return Object.keys(this.contractsMap).filter((chain) => chain !== chainName); } router(contracts) { return contracts.interchainAccountRouter; } static fromAddressesMap(addressesMap, multiProvider) { const helper = appFromAddressesMapHelper(addressesMap, interchainAccountFactories, multiProvider); return new InterchainAccount(helper.contractsMap, helper.multiProvider); } static EMPTY_SALT = '0x' + '00'.repeat(32); async getAccount(destinationChain, config) { return this.getOrDeployAccount(false, destinationChain, config); } async deployAccount(destinationChain, config) { return this.getOrDeployAccount(true, destinationChain, config); } async getOrDeployAccount(deployIfNotExists, destinationChain, config) { const userSalt = config.userSalt ?? InterchainAccount.EMPTY_SALT; const originDomain = this.multiProvider.tryGetDomainId(config.origin); if (!originDomain) { throw new Error(`Origin chain (${config.origin}) metadata needed for deploying ICAs ...`); } const destinationRouter = this.router(this.contractsMap[destinationChain]); const originRouterAddress = config.localRouter ? bytes32ToAddress(config.localRouter) : bytes32ToAddress(await destinationRouter.routers(originDomain)); if (isZeroishAddress(originRouterAddress)) { throw new Error(`Origin router address is zero for ${config.origin} on ${destinationChain}`); } const destinationIsmAddress = bytes32ToAddress(addressToBytes32(config.ismOverride ?? (await destinationRouter.isms(originDomain)))); const destinationAccount = await destinationRouter['getLocalInterchainAccount(uint32,bytes32,bytes32,address,bytes32)'](originDomain, addressToBytes32(config.owner), addressToBytes32(originRouterAddress), destinationIsmAddress, userSalt); // If not deploying anything, return the account address. if (!deployIfNotExists) { return destinationAccount; } // If the account does not exist, deploy it. if ((await this.multiProvider .getProvider(destinationChain) .getCode(destinationAccount)) === '0x') { const txOverrides = this.multiProvider.getTransactionOverrides(destinationChain); // Estimate gas for deployment const gasEstimate = await destinationRouter.estimateGas['getDeployedInterchainAccount(uint32,bytes32,bytes32,address,bytes32)'](originDomain, addressToBytes32(config.owner), addressToBytes32(originRouterAddress), destinationIsmAddress, userSalt); // Add buffer to gas estimate const gasWithBuffer = addBufferToGasLimit(gasEstimate); // Execute deployment with buffered gas estimate await this.multiProvider.handleTx(destinationChain, destinationRouter['getDeployedInterchainAccount(uint32,bytes32,bytes32,address,bytes32)'](originDomain, addressToBytes32(config.owner), addressToBytes32(originRouterAddress), destinationIsmAddress, userSalt, { gasLimit: gasWithBuffer, ...txOverrides, })); this.logger.debug(`Interchain account deployed at ${destinationAccount}`); } else { this.logger.debug(`Interchain account recovered at ${destinationAccount}`); } this.knownAccounts[destinationAccount] = config; return destinationAccount; } /** * Encode the ICA message body for handle() call estimation. * Mirrors solidity/contracts/middleware/libs/InterchainAccountMessage.sol#encode */ encodeIcaMessageBody(owner, ism, calls, salt = ethers.constants.HashZero) { const MESSAGE_TYPE_CALLS = 0; const prefix = ethers.utils.solidityPack(['uint8', 'bytes32', 'bytes32', 'bytes32'], [MESSAGE_TYPE_CALLS, owner, ism, salt]); const suffix = ethers.utils.defaultAbiCoder.encode(['tuple(bytes32 to, uint256 value, bytes data)[]'], [calls]); return ethers.utils.hexConcat([prefix, suffix]); } /** * Estimate gas for ICA handle() execution on destination chain. */ async estimateIcaHandleGas({ origin, destination, innerCalls, config, }) { const originDomain = this.multiProvider.getDomainId(origin); const destinationRouter = config.routerOverride ? InterchainAccountRouter__factory.connect(config.routerOverride, this.multiProvider.getProvider(destination)) : this.router(this.contractsMap[destination]); const localRouterAddress = config.localRouter ? bytes32ToAddress(config.localRouter) : this.routerAddress(origin); const remoteIsm = addressToBytes32(config.ismOverride ?? (await destinationRouter.isms(originDomain))); const formattedCalls = innerCalls.map((call) => ({ to: addressToBytes32(call.to), value: BigNumber.from(call.value ?? '0'), data: call.data, })); const messageBody = this.encodeIcaMessageBody(addressToBytes32(config.owner), remoteIsm, formattedCalls); try { const mailbox = await destinationRouter.mailbox(); const gasEstimate = await estimateHandleGasForRecipient({ recipient: destinationRouter, origin: originDomain, sender: addressToBytes32(localRouterAddress), body: messageBody, mailbox, }); if (gasEstimate) { return addBufferToGasLimit(gasEstimate); } } catch { // Fall through to individual call estimation } this.logger.warn({ destination }, 'Failed to estimate ICA handle gas, trying individual call estimation'); try { const provider = this.multiProvider.getProvider(destination); const individualEstimates = await Promise.all(formattedCalls.map((call) => estimateCallGas({ provider, to: bytes32ToAddress(call.to), data: call.data, value: call.value, }))); const totalGas = individualEstimates.reduce((sum, gas) => sum.add(gas), BigNumber.from(0)); const overhead = ICA_OVERHEAD.add(PER_CALL_OVERHEAD.mul(formattedCalls.length)); return addBufferToGasLimit(totalGas.add(overhead)); } catch { this.logger.warn({ destination }, 'Individual call estimation also failed, using static fallback'); return ICA_HANDLE_GAS_FALLBACK; } } // meant for ICA governance to return the populatedTx async getCallRemote({ chain, destination, innerCalls, config, hookMetadata, }) { const localRouter = config.localRouter ? InterchainAccountRouter__factory.connect(config.localRouter, this.multiProvider.getSigner(chain)) : this.router(this.contractsMap[chain]); const originDomain = this.multiProvider.getDomainId(chain); const remoteDomain = this.multiProvider.getDomainId(destination); const remoteRouter = addressToBytes32(config.routerOverride ?? this.routerAddress(destination)); // ISMs are indexed by origin domain (where messages come FROM) // For legacy routers, we need to use routerOverride to get the ISM const destinationRouterForIsm = config.routerOverride ? InterchainAccountRouter__factory.connect(config.routerOverride, this.multiProvider.getProvider(destination)) : this.router(this.contractsMap[destination]); const remoteIsm = addressToBytes32(config.ismOverride ?? (await destinationRouterForIsm.isms(originDomain))); // Handle both string and object hookMetadata formats const resolvedHookMetadata = typeof hookMetadata === 'string' ? hookMetadata : hookMetadata ? formatStandardHookMetadata({ msgValue: hookMetadata.msgValue ? BigInt(hookMetadata.msgValue) : undefined, gasLimit: hookMetadata.gasLimit ? BigInt(hookMetadata.gasLimit) : undefined, refundAddress: hookMetadata.refundAddress, }) : '0x'; const gasLimitForQuote = typeof hookMetadata === 'object' && hookMetadata?.gasLimit ? BigNumber.from(hookMetadata.gasLimit) : resolvedHookMetadata !== '0x' ? (this.extractGasLimitFromMetadata(resolvedHookMetadata) ?? IGP_DEFAULT_GAS) : IGP_DEFAULT_GAS; const formattedCalls = innerCalls.map((call) => ({ to: addressToBytes32(call.to), value: BigNumber.from(call.value ?? '0'), data: call.data, })); let quote; try { quote = await localRouter['quoteGasPayment(uint32,uint256)'](remoteDomain, gasLimitForQuote); } catch { // Legacy ICA routers have broken quoteGasPayment that doesn't use hookMetadata. // Query the mailbox directly to get accurate quote with our metadata. const mailboxAddress = await localRouter.mailbox(); const mailbox = new ethers.Contract(mailboxAddress, [ 'function quoteDispatch(uint32,bytes32,bytes,bytes,address) view returns (uint256)', 'function defaultHook() view returns (address)', ], this.multiProvider.getProvider(chain)); const defaultHook = await mailbox.defaultHook(); const messageBody = this.encodeIcaMessageBody(addressToBytes32(config.owner), remoteIsm, formattedCalls); quote = await mailbox['quoteDispatch(uint32,bytes32,bytes,bytes,address)'](remoteDomain, remoteRouter, messageBody, resolvedHookMetadata, defaultHook); } const callEncoded = await localRouter.populateTransaction['callRemoteWithOverrides(uint32,bytes32,bytes32,(bytes32,uint256,bytes)[],bytes)'](remoteDomain, remoteRouter, remoteIsm, formattedCalls, resolvedHookMetadata, { value: quote }); return callEncoded; } extractGasLimitFromMetadata(metadata) { const parsed = parseStandardHookMetadata(metadata); return parsed ? BigNumber.from(parsed.gasLimit) : null; } // general helper for different overloaded callRemote functions // can override the gasLimit by StandardHookMetadata.overrideGasLimit for optional hookMetadata here async callRemote({ chain, destination, innerCalls, config, hookMetadata, }) { await this.multiProvider.sendTransaction(chain, this.getCallRemote({ chain, destination, innerCalls, config, hookMetadata, })); } } export async function buildInterchainAccountApp(multiProvider, chain, config, coreAddressesByChain) { if (!config.localRouter) { throw new Error('localRouter is required for account deployment'); } let remoteIcaAddresses; const localChainAddresses = coreAddressesByChain[chain]; // if the user specified a custom router address we need to retrieve the remote ica addresses // configured on the user provided router, otherwise we use the ones defined in the registry if (localChainAddresses?.interchainAccountRouter && eqAddress(config.localRouter, localChainAddresses.interchainAccountRouter)) { remoteIcaAddresses = objMap(coreAddressesByChain, (_, chainAddresses) => ({ interchainAccountRouter: chainAddresses.interchainAccountRouter, })); } else { const currentIca = InterchainAccountRouter__factory.connect(config.localRouter, multiProvider.getSigner(chain)); const knownDomains = await currentIca.domains(); remoteIcaAddresses = await promiseObjAll(objMap(arrayToObject(knownDomains.map(String)), async (domainId) => { const routerAddress = await currentIca.routers(domainId); return { interchainAccountRouter: bytes32ToAddress(routerAddress) }; })); } // remove the undefined or 0 addresses values remoteIcaAddresses = objFilter(remoteIcaAddresses, (_chainId, chainAddresses) => !!chainAddresses.interchainAccountRouter && !isZeroishAddress(chainAddresses.interchainAccountRouter)); const addressesMap = { [chain]: { interchainAccountRouter: config.localRouter, }, ...remoteIcaAddresses, }; return InterchainAccount.fromAddressesMap(addressesMap, multiProvider); } export async function deployInterchainAccount(multiProvider, chain, config, coreAddressesByChain) { const interchainAccountApp = await buildInterchainAccountApp(multiProvider, chain, config, coreAddressesByChain); return interchainAccountApp.deployAccount(chain, config); } export async function shareCallsWithPrivateRelayer(serverUrl, payload) { const resp = await fetch(serverUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { // Read body const body = await resp.text(); throw new Error(`Failed to share calls with relayer: ${resp.status} ${body}`); } return resp; } //# sourceMappingURL=InterchainAccount.js.map