@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
262 lines • 14.5 kB
JavaScript
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