diff --git a/.changeset/fifty-meals-appear.md b/.changeset/fifty-meals-appear.md new file mode 100644 index 0000000000..7e754f3ec9 --- /dev/null +++ b/.changeset/fifty-meals-appear.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/sdk': minor +--- + +Add logic into SDK to enable warp route unenrollment diff --git a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts index 82d3190daa..79f954d321 100644 --- a/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts +++ b/typescript/sdk/src/token/EvmERC20WarpModule.hardhat-test.ts @@ -27,6 +27,7 @@ import { proxyAdmin, serializeContracts, } from '@hyperlane-xyz/sdk'; +import { randomInt } from '@hyperlane-xyz/utils'; import { TestCoreApp } from '../core/TestCoreApp.js'; import { TestCoreDeployer } from '../core/TestCoreDeployer.js'; @@ -510,7 +511,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => { ); }); - it('should update connected routers', async () => { + it('should enroll connected routers', async () => { const config = { ...baseConfig, type: TokenType.native, @@ -527,7 +528,7 @@ describe('EvmERC20WarpHyperlaneModule', async () => { multiProvider, proxyFactoryFactories: ismFactoryAddresses, }); - const numOfRouters = Math.floor(Math.random() * 10); + const numOfRouters = randomInt(10, 0); await sendTxs( await evmERC20WarpModule.update({ ...config, @@ -541,7 +542,44 @@ describe('EvmERC20WarpHyperlaneModule', async () => { ); }); - it('should only extend routers if they are new ones are different', async () => { + it('should unenroll connected routers', async () => { + const config = { + ...baseConfig, + type: TokenType.native, + ismFactoryAddresses, + } as TokenRouterConfig; + + // Deploy using WarpModule + const evmERC20WarpModule = await EvmERC20WarpModule.create({ + chain, + config: { + ...config, + interchainSecurityModule: ismAddress, + }, + multiProvider, + proxyFactoryFactories: ismFactoryAddresses, + }); + const numOfRouters = randomInt(10, 0); + await sendTxs( + await evmERC20WarpModule.update({ + ...config, + remoteRouters: randomRemoteRouters(numOfRouters), + }), + ); + // Read config & delete remoteRouters + const existingConfig = await evmERC20WarpModule.read(); + for (let i = 0; i < numOfRouters; i++) { + delete existingConfig.remoteRouters?.[i.toString()]; + await sendTxs(await evmERC20WarpModule.update(existingConfig)); + + const updatedConfig = await evmERC20WarpModule.read(); + expect(Object.keys(updatedConfig.remoteRouters!).length).to.be.equal( + numOfRouters - (i + 1), + ); + } + }); + + it('should replace an enrollment if they are new one different, if the config lengths are the same', async () => { const config = { ...baseConfig, type: TokenType.native, @@ -579,20 +617,24 @@ describe('EvmERC20WarpHyperlaneModule', async () => { await sendTxs(txs); // Try to extend with the different remoteRouters, but same length + const extendedRemoteRouter = { + 3: { + address: randomAddress(), + }, + }; txs = await evmERC20WarpModule.update({ ...config, - remoteRouters: { - 3: { - address: randomAddress(), - }, - }, + remoteRouters: extendedRemoteRouter, }); - expect(txs.length).to.equal(1); + expect(txs.length).to.equal(2); await sendTxs(txs); updatedConfig = await evmERC20WarpModule.read(); - expect(Object.keys(updatedConfig.remoteRouters!).length).to.be.equal(2); + expect(Object.keys(updatedConfig.remoteRouters!).length).to.be.equal(1); + expect(updatedConfig.remoteRouters?.['3'].address.toLowerCase()).to.be.eq( + extendedRemoteRouter['3'].address.toLowerCase(), + ); }); it('should update the owner only if they are different', async () => { diff --git a/typescript/sdk/src/token/EvmERC20WarpModule.ts b/typescript/sdk/src/token/EvmERC20WarpModule.ts index fd497196e5..7fdeb65b49 100644 --- a/typescript/sdk/src/token/EvmERC20WarpModule.ts +++ b/typescript/sdk/src/token/EvmERC20WarpModule.ts @@ -19,6 +19,7 @@ import { addressToBytes32, assert, deepEquals, + difference, eqAddress, isObjEmpty, objMap, @@ -39,7 +40,6 @@ import { DerivedIsmConfig } from '../ism/EvmIsmReader.js'; import { MultiProvider } from '../providers/MultiProvider.js'; import { AnnotatedEV5Transaction } from '../providers/ProviderType.js'; import { ChainName, ChainNameOrId } from '../types.js'; -import { normalizeConfig } from '../utils/ism.js'; import { EvmERC20WarpRouteReader } from './EvmERC20WarpRouteReader.js'; import { HypERC20Deployer } from './deploy.js'; @@ -115,7 +115,11 @@ export class EvmERC20WarpModule extends HyperlaneModule< transactions.push( ...(await this.createIsmUpdateTxs(actualConfig, expectedConfig)), ...(await this.createHookUpdateTxs(actualConfig, expectedConfig)), - ...this.createRemoteRoutersUpdateTxs(actualConfig, expectedConfig), + ...this.createEnrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig), + ...this.createUnenrollRemoteRoutersUpdateTxs( + actualConfig, + expectedConfig, + ), ...this.createSetDestinationGasUpdateTxs(actualConfig, expectedConfig), ...this.createOwnershipUpdateTxs(actualConfig, expectedConfig), ...proxyAdminUpdateTxs( @@ -136,7 +140,7 @@ export class EvmERC20WarpModule extends HyperlaneModule< * @param expectedConfig - The expected token router configuration. * @returns A array with a single Ethereum transaction that need to be executed to enroll the routers */ - createRemoteRoutersUpdateTxs( + createEnrollRemoteRoutersUpdateTxs( actualConfig: TokenRouterConfig, expectedConfig: TokenRouterConfig, ): AnnotatedEV5Transaction[] { @@ -148,46 +152,84 @@ export class EvmERC20WarpModule extends HyperlaneModule< assert(actualConfig.remoteRouters, 'actualRemoteRouters is undefined'); assert(expectedConfig.remoteRouters, 'actualRemoteRouters is undefined'); - // We normalize the addresses for comparison - actualConfig.remoteRouters = Object.fromEntries( - Object.entries(actualConfig.remoteRouters).map(([key, value]) => [ - key, - // normalizeConfig removes the address property but we don't want to lose that info - { ...normalizeConfig(value), address: normalizeConfig(value.address) }, - ]), + const { remoteRouters: actualRemoteRouters } = actualConfig; + const { remoteRouters: expectedRemoteRouters } = expectedConfig; + + const routesToEnroll = Array.from( + difference( + new Set(Object.keys(expectedRemoteRouters)), + new Set(Object.keys(actualRemoteRouters)), + ), ); - expectedConfig.remoteRouters = Object.fromEntries( - Object.entries(expectedConfig.remoteRouters).map(([key, value]) => [ - key, - // normalizeConfig removes the address property but we don't want to lose that info - { ...normalizeConfig(value), address: normalizeConfig(value.address) }, - ]), + + 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: TokenRouterConfig, + expectedConfig: TokenRouterConfig, + ): AnnotatedEV5Transaction[] { + const updateTransactions: AnnotatedEV5Transaction[] = []; + if (!expectedConfig.remoteRouters) { + return []; + } + + assert(actualConfig.remoteRouters, 'actualRemoteRouters is undefined'); + assert(expectedConfig.remoteRouters, 'actualRemoteRouters is undefined'); + const { remoteRouters: actualRemoteRouters } = actualConfig; const { remoteRouters: expectedRemoteRouters } = expectedConfig; - if (!deepEquals(actualRemoteRouters, expectedRemoteRouters)) { - const contractToUpdate = TokenRouter__factory.connect( - this.args.addresses.deployedTokenRoute, - this.multiProvider.getProvider(this.domainId), - ); + const routesToUnenroll = Array.from( + difference( + new Set(Object.keys(actualRemoteRouters)), + new Set(Object.keys(expectedRemoteRouters)), + ), + ); - updateTransactions.push({ - chainId: this.chainId, - annotation: `Enrolling Router ${this.args.addresses.deployedTokenRoute} on ${this.args.chain}`, - to: contractToUpdate.address, - data: contractToUpdate.interface.encodeFunctionData( - 'enrollRemoteRouters', - [ - Object.keys(expectedRemoteRouters).map((k) => Number(k)), - Object.values(expectedRemoteRouters).map((a) => - addressToBytes32(a.address), - ), - ], - ), - }); + 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; }