Skip to content

Commit e1ed158

Browse files
authored
fix(sdk): enroll user-specified remoteRouters during warp deployment (#8465)
1 parent a27aa2c commit e1ed158

5 files changed

Lines changed: 274 additions & 19 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hyperlane-xyz/sdk': patch
3+
'@hyperlane-xyz/cli': patch
4+
---
5+
6+
User-specified remoteRouters and destinationGas in warp deploy configs were ignored during router enrollment when the remote chains were not part of the deployment. enrollCrossChainRouters now merges user-specified entries with auto-discovered routers from deployed contracts.

.github/workflows/test-cli-e2e.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ jobs:
102102
- warp-bridge-2
103103
- warp-deploy-1
104104
- warp-deploy-2
105+
- warp-deploy-remote-routers
105106
# check
106107
- warp-check-1
107108
- warp-check-2
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import * as chai from 'chai';
2+
import chaiAsPromised from 'chai-as-promised';
3+
import { Wallet } from 'ethers';
4+
5+
import { type ChainAddresses } from '@hyperlane-xyz/registry';
6+
import {
7+
TokenType,
8+
type WarpRouteDeployConfig,
9+
randomAddress,
10+
} from '@hyperlane-xyz/sdk';
11+
import {
12+
type Address,
13+
addressToBytes32,
14+
assert,
15+
isObjEmpty,
16+
} from '@hyperlane-xyz/utils';
17+
18+
import { readYamlOrJson, writeYamlOrJson } from '../../../utils/files.js';
19+
import { deployOrUseExistingCore } from '../commands/core.js';
20+
import { getDomainId } from '../commands/helpers.js';
21+
import {
22+
hyperlaneWarpDeploy,
23+
hyperlaneWarpReadRaw,
24+
resolveWarpRouteIdForDeploy,
25+
} from '../commands/warp.js';
26+
import {
27+
ANVIL_KEY,
28+
CHAIN_NAME_2,
29+
CHAIN_NAME_3,
30+
CHAIN_NAME_4,
31+
CORE_CONFIG_PATH,
32+
DEFAULT_E2E_TEST_TIMEOUT,
33+
IS_TRON_TEST,
34+
TRON_KEY_1,
35+
WARP_DEPLOY_OUTPUT_PATH,
36+
} from '../consts.js';
37+
38+
chai.use(chaiAsPromised);
39+
const expect = chai.expect;
40+
41+
describe('hyperlane warp deploy with user-specified remote routers', async function () {
42+
this.timeout(2 * DEFAULT_E2E_TEST_TIMEOUT);
43+
44+
let chain2Addresses: ChainAddresses = {};
45+
let chain3Addresses: ChainAddresses = {};
46+
let ownerAddress: Address;
47+
let chain2DomainId: string;
48+
let chain3DomainId: string;
49+
let chain4DomainId: string;
50+
51+
before(async function () {
52+
ownerAddress = new Wallet(ANVIL_KEY).address;
53+
const chain3Key = IS_TRON_TEST ? TRON_KEY_1 : ANVIL_KEY;
54+
[chain2Addresses, chain3Addresses] = await Promise.all([
55+
deployOrUseExistingCore(CHAIN_NAME_2, CORE_CONFIG_PATH, ANVIL_KEY),
56+
deployOrUseExistingCore(CHAIN_NAME_3, CORE_CONFIG_PATH, chain3Key),
57+
]);
58+
[chain2DomainId, chain3DomainId, chain4DomainId] = await Promise.all([
59+
getDomainId(CHAIN_NAME_2, ANVIL_KEY),
60+
getDomainId(CHAIN_NAME_3, ANVIL_KEY),
61+
getDomainId(CHAIN_NAME_4, ANVIL_KEY),
62+
]);
63+
});
64+
65+
it('should enroll user-specified remote routers for chains not in the deploy config', async function () {
66+
// Deploy only on anvil2 with remoteRouters pointing to anvil3 (not in deploy config)
67+
const fakeRemoteRouterAddress = randomAddress();
68+
69+
const warpConfig: WarpRouteDeployConfig = {
70+
[CHAIN_NAME_2]: {
71+
type: TokenType.native,
72+
mailbox: chain2Addresses.mailbox,
73+
owner: ownerAddress,
74+
symbol: 'ETH',
75+
name: 'Ether',
76+
remoteRouters: {
77+
[CHAIN_NAME_3]: {
78+
address: addressToBytes32(fakeRemoteRouterAddress),
79+
},
80+
},
81+
},
82+
};
83+
84+
writeYamlOrJson(WARP_DEPLOY_OUTPUT_PATH, warpConfig);
85+
const resolvedWarpRouteId = await resolveWarpRouteIdForDeploy({
86+
warpDeployPath: WARP_DEPLOY_OUTPUT_PATH,
87+
});
88+
89+
await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, resolvedWarpRouteId);
90+
91+
// Read back the deployed config
92+
await hyperlaneWarpReadRaw({
93+
warpRouteId: resolvedWarpRouteId,
94+
outputPath: WARP_DEPLOY_OUTPUT_PATH,
95+
});
96+
const deployedConfig = readYamlOrJson(
97+
WARP_DEPLOY_OUTPUT_PATH,
98+
) as WarpRouteDeployConfig;
99+
100+
// Verify the user-specified remote router was enrolled
101+
const remoteRouters = deployedConfig[CHAIN_NAME_2].remoteRouters;
102+
assert(remoteRouters, 'Expected remoteRouters to be defined');
103+
expect(Object.keys(remoteRouters)).to.include(chain3DomainId);
104+
expect(remoteRouters[chain3DomainId].address).to.equal(
105+
addressToBytes32(fakeRemoteRouterAddress),
106+
);
107+
108+
// Verify destinationGas defaults to MAX_GAS_OVERHEAD for user-specified remote routers
109+
const destinationGas = deployedConfig[CHAIN_NAME_2].destinationGas;
110+
assert(destinationGas, 'Expected destinationGas to be defined');
111+
expect(destinationGas[chain3DomainId]).to.equal('68000');
112+
});
113+
114+
it('should enroll user-specified remote routers alongside routers from other deployed chains', async function () {
115+
// Deploy on both anvil2 and anvil3, but also specify a remote router
116+
// for anvil4 which is not part of the deployment
117+
const fakeRemoteRouterAddress = randomAddress();
118+
119+
const warpConfig: WarpRouteDeployConfig = {
120+
[CHAIN_NAME_2]: {
121+
type: TokenType.native,
122+
mailbox: chain2Addresses.mailbox,
123+
owner: ownerAddress,
124+
symbol: 'ETH',
125+
name: 'Ether',
126+
remoteRouters: {
127+
[CHAIN_NAME_4]: {
128+
address: addressToBytes32(fakeRemoteRouterAddress),
129+
},
130+
},
131+
},
132+
[CHAIN_NAME_3]: {
133+
type: TokenType.synthetic,
134+
mailbox: chain3Addresses.mailbox,
135+
owner: ownerAddress,
136+
},
137+
};
138+
139+
writeYamlOrJson(WARP_DEPLOY_OUTPUT_PATH, warpConfig);
140+
const resolvedWarpRouteId = await resolveWarpRouteIdForDeploy({
141+
warpDeployPath: WARP_DEPLOY_OUTPUT_PATH,
142+
});
143+
144+
await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, resolvedWarpRouteId);
145+
146+
// Read back the deployed config for all chains
147+
await hyperlaneWarpReadRaw({
148+
warpRouteId: resolvedWarpRouteId,
149+
outputPath: WARP_DEPLOY_OUTPUT_PATH,
150+
});
151+
const deployedConfig = readYamlOrJson(
152+
WARP_DEPLOY_OUTPUT_PATH,
153+
) as WarpRouteDeployConfig;
154+
155+
// Verify anvil2 has both: the user-specified anvil4 AND the auto-discovered anvil3
156+
const remoteRouters2 = deployedConfig[CHAIN_NAME_2].remoteRouters;
157+
assert(remoteRouters2, 'Expected remoteRouters to be defined');
158+
expect(Object.keys(remoteRouters2)).to.include(chain4DomainId);
159+
expect(Object.keys(remoteRouters2)).to.include(chain3DomainId);
160+
expect(remoteRouters2[chain4DomainId].address).to.equal(
161+
addressToBytes32(fakeRemoteRouterAddress),
162+
);
163+
164+
// Verify destinationGas on anvil2
165+
const destinationGas2 = deployedConfig[CHAIN_NAME_2].destinationGas;
166+
assert(destinationGas2, 'Expected destinationGas to be defined');
167+
expect(destinationGas2[chain4DomainId]).to.equal('68000');
168+
expect(destinationGas2[chain3DomainId]).to.equal('64000');
169+
170+
// Verify anvil3 does NOT include anvil4 — user-specified routers are scoped per-chain
171+
const remoteRouters3 = deployedConfig[CHAIN_NAME_3].remoteRouters;
172+
assert(remoteRouters3, 'Expected remoteRouters to be defined');
173+
expect(Object.keys(remoteRouters3)).to.include(chain2DomainId);
174+
expect(Object.keys(remoteRouters3)).to.not.include(chain4DomainId);
175+
});
176+
177+
it('should not enroll any remote routers when none are specified and only one chain is deployed', async function () {
178+
// Deploy only on anvil2 with NO remoteRouters
179+
const warpConfig: WarpRouteDeployConfig = {
180+
[CHAIN_NAME_2]: {
181+
type: TokenType.native,
182+
mailbox: chain2Addresses.mailbox,
183+
owner: ownerAddress,
184+
symbol: 'ETH',
185+
name: 'Ether',
186+
},
187+
};
188+
189+
writeYamlOrJson(WARP_DEPLOY_OUTPUT_PATH, warpConfig);
190+
const resolvedWarpRouteId = await resolveWarpRouteIdForDeploy({
191+
warpDeployPath: WARP_DEPLOY_OUTPUT_PATH,
192+
});
193+
194+
await hyperlaneWarpDeploy(WARP_DEPLOY_OUTPUT_PATH, resolvedWarpRouteId);
195+
196+
// Read back the deployed config
197+
await hyperlaneWarpReadRaw({
198+
warpRouteId: resolvedWarpRouteId,
199+
outputPath: WARP_DEPLOY_OUTPUT_PATH,
200+
});
201+
const deployedConfig = readYamlOrJson(
202+
WARP_DEPLOY_OUTPUT_PATH,
203+
) as WarpRouteDeployConfig;
204+
205+
// Verify no remote routers were enrolled
206+
const remoteRouters = deployedConfig[CHAIN_NAME_2].remoteRouters;
207+
expect(
208+
!remoteRouters || isObjEmpty(remoteRouters),
209+
'Expected no remote routers to be enrolled',
210+
).to.be.true;
211+
});
212+
});

typescript/sdk/src/deploy/warp.ts

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,13 @@ import { IsmConfig } from '../ism/types.js';
5353
import { altVmChainLookup } from '../metadata/ChainMetadataManager.js';
5454
import { MultiProvider } from '../providers/MultiProvider.js';
5555
import { TypedAnnotatedTransaction } from '../providers/ProviderType.js';
56-
import { DestinationGas, RemoteRouters } from '../router/types.js';
56+
import {
57+
DestinationGas,
58+
RemoteRouters,
59+
resolveRouterMapConfig,
60+
} from '../router/types.js';
5761
import { EvmWarpModule } from '../token/EvmWarpModule.js';
58-
import { TokenType, gasOverhead } from '../token/config.js';
62+
import { MAX_GAS_OVERHEAD, TokenType, gasOverhead } from '../token/config.js';
5963
import { HypERC20Factories, hypERC20factories } from '../token/contracts.js';
6064
import { HypERC20Deployer, HypERC721Deployer } from '../token/deploy.js';
6165
import {
@@ -523,26 +527,56 @@ export async function enrollCrossChainRouters(
523527
async (currentChain) => {
524528
const protocol = multiProvider.getProtocol(currentChain);
525529

526-
const remoteRouters: RemoteRouters = Object.fromEntries(
527-
Object.entries(deployedContracts)
528-
.filter(([chain, _address]) => chain !== currentChain)
529-
.map(([chain, address]) => [
530-
multiProvider.getDomainId(chain).toString(),
531-
{
532-
address: addressToBytes32(address),
533-
},
534-
]),
530+
// Start with user-specified remote routers (for chains not in the deployment)
531+
const userRemoteRouters: RemoteRouters = objMap(
532+
resolveRouterMapConfig(
533+
multiProvider,
534+
resolvedConfigMap[currentChain].remoteRouters ?? {},
535+
),
536+
(_, value) => ({ address: addressToBytes32(value.address) }),
537+
);
538+
539+
// Merge: deployed routers take precedence over user-specified
540+
const remoteRouters: RemoteRouters = {
541+
...userRemoteRouters,
542+
...Object.fromEntries(
543+
Object.entries(deployedContracts)
544+
.filter(([chain, _address]) => chain !== currentChain)
545+
.map(([chain, address]) => [
546+
multiProvider.getDomainId(chain).toString(),
547+
{
548+
address: addressToBytes32(address),
549+
},
550+
]),
551+
),
552+
};
553+
554+
// Start with user-specified destination gas
555+
const userDestinationGas: DestinationGas = resolveRouterMapConfig(
556+
multiProvider,
557+
resolvedConfigMap[currentChain].destinationGas ?? {},
535558
);
536559

537-
const destinationGas: DestinationGas = Object.fromEntries(
538-
Object.entries(deployedContracts)
539-
.filter(([chain, _address]) => chain !== currentChain)
540-
.map(([chain, _address]) => [
541-
multiProvider.getDomainId(chain).toString(),
542-
resolvedConfigMap[chain].gas.toString(),
543-
]),
560+
// Default to MAX_GAS_OVERHEAD for user-specified remote routers without explicit destinationGas
561+
const defaultGasForUserRouters: DestinationGas = objMap(
562+
userRemoteRouters,
563+
(domainId) =>
564+
userDestinationGas[domainId] ?? MAX_GAS_OVERHEAD.toString(),
544565
);
545566

567+
// Merge: deployed chain gas takes precedence over defaults and user-specified
568+
const destinationGas: DestinationGas = {
569+
...defaultGasForUserRouters,
570+
...Object.fromEntries(
571+
Object.entries(deployedContracts)
572+
.filter(([chain, _address]) => chain !== currentChain)
573+
.map(([chain, _address]) => [
574+
multiProvider.getDomainId(chain).toString(),
575+
resolvedConfigMap[chain].gas.toString(),
576+
]),
577+
),
578+
};
579+
546580
for (const domainId of Object.keys(remoteRouters)) {
547581
rootLogger.debug(
548582
`Creating enroll remote router transactions with remote domain id ${domainId} and address ${remoteRouters[domainId]} on chain ${currentChain}`,

typescript/sdk/src/token/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export function isMovableCollateralTokenType(type: TokenType): boolean {
6666
return !!isMovableCollateralTokenTypeMap[type];
6767
}
6868

69+
export const MAX_GAS_OVERHEAD = 68_000;
70+
6971
export const gasOverhead = (tokenType: TokenType): number => {
7072
switch (tokenType) {
7173
case TokenType.synthetic:
@@ -74,7 +76,7 @@ export const gasOverhead = (tokenType: TokenType): number => {
7476
case TokenType.nativeScaled:
7577
return 44_000;
7678
default:
77-
return 68_000;
79+
return MAX_GAS_OVERHEAD;
7880
}
7981
};
8082

0 commit comments

Comments
 (0)