@hyperlane-xyz/sdk 41.3.1 → 42.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/dist/ccip/consts.d.ts.map +1 -1
  2. package/dist/ccip/consts.js +20 -0
  3. package/dist/ccip/consts.js.map +1 -1
  4. package/dist/consts/multisigIsm.d.ts.map +1 -1
  5. package/dist/consts/multisigIsm.js +20 -0
  6. package/dist/consts/multisigIsm.js.map +1 -1
  7. package/dist/contracts/contracts.d.ts.map +1 -1
  8. package/dist/contracts/contracts.js +15 -0
  9. package/dist/contracts/contracts.js.map +1 -1
  10. package/dist/contracts/contracts.test.d.ts +2 -0
  11. package/dist/contracts/contracts.test.d.ts.map +1 -0
  12. package/dist/contracts/contracts.test.js +88 -0
  13. package/dist/contracts/contracts.test.js.map +1 -0
  14. package/dist/core/types.d.ts +80 -2602
  15. package/dist/core/types.d.ts.map +1 -1
  16. package/dist/core/types.js +19 -1
  17. package/dist/core/types.js.map +1 -1
  18. package/dist/core/types.test.js +58 -0
  19. package/dist/core/types.test.js.map +1 -1
  20. package/dist/deploy/warp.d.ts +119 -3
  21. package/dist/deploy/warp.d.ts.map +1 -1
  22. package/dist/deploy/warp.js +696 -33
  23. package/dist/deploy/warp.js.map +1 -1
  24. package/dist/deploy/warp.test.d.ts +2 -0
  25. package/dist/deploy/warp.test.d.ts.map +1 -0
  26. package/dist/deploy/warp.test.js +358 -0
  27. package/dist/deploy/warp.test.js.map +1 -0
  28. package/dist/deploy/warpHybridPlan.d.ts +104 -0
  29. package/dist/deploy/warpHybridPlan.d.ts.map +1 -0
  30. package/dist/deploy/warpHybridPlan.js +178 -0
  31. package/dist/deploy/warpHybridPlan.js.map +1 -0
  32. package/dist/deploy/warpHybridPlan.test.d.ts +2 -0
  33. package/dist/deploy/warpHybridPlan.test.d.ts.map +1 -0
  34. package/dist/deploy/warpHybridPlan.test.js +141 -0
  35. package/dist/deploy/warpHybridPlan.test.js.map +1 -0
  36. package/dist/hook/EvmHookModule.d.ts +1 -1
  37. package/dist/hook/EvmHookModule.d.ts.map +1 -1
  38. package/dist/hook/EvmHookModule.js +16 -3
  39. package/dist/hook/EvmHookModule.js.map +1 -1
  40. package/dist/hook/EvmHookReader.d.ts +10 -0
  41. package/dist/hook/EvmHookReader.d.ts.map +1 -1
  42. package/dist/hook/EvmHookReader.js +92 -11
  43. package/dist/hook/EvmHookReader.js.map +1 -1
  44. package/dist/hook/EvmHookReader.test.js +155 -1
  45. package/dist/hook/EvmHookReader.test.js.map +1 -1
  46. package/dist/hook/types.d.ts +107 -1983
  47. package/dist/hook/types.d.ts.map +1 -1
  48. package/dist/hook/types.js +49 -0
  49. package/dist/hook/types.js.map +1 -1
  50. package/dist/hook/types.test.js +21 -1
  51. package/dist/hook/types.test.js.map +1 -1
  52. package/dist/hook/updates.d.ts +1 -0
  53. package/dist/hook/updates.d.ts.map +1 -1
  54. package/dist/hook/updates.js +2 -2
  55. package/dist/hook/updates.js.map +1 -1
  56. package/dist/hook/utils.d.ts +28 -1
  57. package/dist/hook/utils.d.ts.map +1 -1
  58. package/dist/hook/utils.js +89 -0
  59. package/dist/hook/utils.js.map +1 -1
  60. package/dist/ica/types.d.ts +4 -674
  61. package/dist/ica/types.d.ts.map +1 -1
  62. package/dist/index.d.ts +6 -5
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +6 -5
  65. package/dist/index.js.map +1 -1
  66. package/dist/ism/EvmIsmModule.d.ts +28 -2
  67. package/dist/ism/EvmIsmModule.d.ts.map +1 -1
  68. package/dist/ism/EvmIsmModule.hardhat-test.js +478 -3
  69. package/dist/ism/EvmIsmModule.hardhat-test.js.map +1 -1
  70. package/dist/ism/EvmIsmModule.js +224 -10
  71. package/dist/ism/EvmIsmModule.js.map +1 -1
  72. package/dist/ism/EvmIsmReader.d.ts.map +1 -1
  73. package/dist/ism/EvmIsmReader.js +77 -1
  74. package/dist/ism/EvmIsmReader.js.map +1 -1
  75. package/dist/ism/EvmIsmReader.test.js +245 -4
  76. package/dist/ism/EvmIsmReader.test.js.map +1 -1
  77. package/dist/ism/HyperlaneIsmFactory.d.ts +14 -4
  78. package/dist/ism/HyperlaneIsmFactory.d.ts.map +1 -1
  79. package/dist/ism/HyperlaneIsmFactory.hardhat-test.js +372 -3
  80. package/dist/ism/HyperlaneIsmFactory.hardhat-test.js.map +1 -1
  81. package/dist/ism/HyperlaneIsmFactory.js +80 -6
  82. package/dist/ism/HyperlaneIsmFactory.js.map +1 -1
  83. package/dist/ism/delayedFlow.d.ts +31 -0
  84. package/dist/ism/delayedFlow.d.ts.map +1 -0
  85. package/dist/ism/delayedFlow.js +33 -0
  86. package/dist/ism/delayedFlow.js.map +1 -0
  87. package/dist/ism/types.d.ts +135 -4
  88. package/dist/ism/types.d.ts.map +1 -1
  89. package/dist/ism/types.js +263 -11
  90. package/dist/ism/types.js.map +1 -1
  91. package/dist/ism/types.test.js +433 -1
  92. package/dist/ism/types.test.js.map +1 -1
  93. package/dist/ism/utils.d.ts.map +1 -1
  94. package/dist/ism/utils.js +113 -3
  95. package/dist/ism/utils.js.map +1 -1
  96. package/dist/ism/utils.test.js +44 -0
  97. package/dist/ism/utils.test.js.map +1 -1
  98. package/dist/providers/MultiProviderAdapter.d.ts +3 -3
  99. package/dist/providers/MultiProviderAdapter.d.ts.map +1 -1
  100. package/dist/providers/MultiProviderAdapter.js +2 -1
  101. package/dist/providers/MultiProviderAdapter.js.map +1 -1
  102. package/dist/providers/SmartProvider/HyperlaneJsonRpcProvider.d.ts.map +1 -1
  103. package/dist/providers/SmartProvider/HyperlaneJsonRpcProvider.js +4 -0
  104. package/dist/providers/SmartProvider/HyperlaneJsonRpcProvider.js.map +1 -1
  105. package/dist/providers/transactionFeeEstimators.d.ts +16 -9
  106. package/dist/providers/transactionFeeEstimators.d.ts.map +1 -1
  107. package/dist/providers/transactionFeeEstimators.js +66 -17
  108. package/dist/providers/transactionFeeEstimators.js.map +1 -1
  109. package/dist/providers/transactionFeeEstimators.test.js +153 -1
  110. package/dist/providers/transactionFeeEstimators.test.js.map +1 -1
  111. package/dist/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.d.ts +14 -1
  112. package/dist/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.d.ts.map +1 -1
  113. package/dist/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.js +46 -5
  114. package/dist/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.js.map +1 -1
  115. package/dist/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.test.d.ts +2 -0
  116. package/dist/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.test.d.ts.map +1 -0
  117. package/dist/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.test.js +125 -0
  118. package/dist/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.test.js.map +1 -0
  119. package/dist/router/HyperlaneRouterDeployer.d.ts +3 -1
  120. package/dist/router/HyperlaneRouterDeployer.d.ts.map +1 -1
  121. package/dist/router/HyperlaneRouterDeployer.js +4 -2
  122. package/dist/router/HyperlaneRouterDeployer.js.map +1 -1
  123. package/dist/router/types.d.ts +6 -1011
  124. package/dist/router/types.d.ts.map +1 -1
  125. package/dist/test/testUtils.d.ts +1 -1
  126. package/dist/test/testUtils.d.ts.map +1 -1
  127. package/dist/test/testUtils.js +17 -0
  128. package/dist/test/testUtils.js.map +1 -1
  129. package/dist/test/testUtils.test.d.ts +2 -0
  130. package/dist/test/testUtils.test.d.ts.map +1 -0
  131. package/dist/test/testUtils.test.js +28 -0
  132. package/dist/test/testUtils.test.js.map +1 -0
  133. package/dist/token/EvmWarpModule.d.ts +44 -8
  134. package/dist/token/EvmWarpModule.d.ts.map +1 -1
  135. package/dist/token/EvmWarpModule.hardhat-test.js +574 -4
  136. package/dist/token/EvmWarpModule.hardhat-test.js.map +1 -1
  137. package/dist/token/EvmWarpModule.js +237 -25
  138. package/dist/token/EvmWarpModule.js.map +1 -1
  139. package/dist/token/configUtils.d.ts +17 -7
  140. package/dist/token/configUtils.d.ts.map +1 -1
  141. package/dist/token/configUtils.js +108 -0
  142. package/dist/token/configUtils.js.map +1 -1
  143. package/dist/token/configUtils.test.js +155 -1
  144. package/dist/token/configUtils.test.js.map +1 -1
  145. package/dist/token/deploy.d.ts +3 -1
  146. package/dist/token/deploy.d.ts.map +1 -1
  147. package/dist/token/deploy.hardhat-test.js +454 -3
  148. package/dist/token/deploy.hardhat-test.js.map +1 -1
  149. package/dist/token/deploy.js +25 -6
  150. package/dist/token/deploy.js.map +1 -1
  151. package/dist/token/types.d.ts +169 -2931
  152. package/dist/token/types.d.ts.map +1 -1
  153. package/dist/token/types.test.js +48 -0
  154. package/dist/token/types.test.js.map +1 -1
  155. package/dist/token/warpCheck.d.ts.map +1 -1
  156. package/dist/token/warpCheck.js +36 -1
  157. package/dist/token/warpCheck.js.map +1 -1
  158. package/dist/utils/ism.d.ts +167 -1
  159. package/dist/utils/ism.d.ts.map +1 -1
  160. package/dist/utils/ism.js +412 -2
  161. package/dist/utils/ism.js.map +1 -1
  162. package/dist/utils/ism.test.d.ts +2 -0
  163. package/dist/utils/ism.test.d.ts.map +1 -0
  164. package/dist/utils/ism.test.js +372 -0
  165. package/dist/utils/ism.test.js.map +1 -0
  166. package/package.json +10 -10
@@ -1,4 +1,4 @@
1
- import { MailboxClient__factory, ProxyAdmin__factory, } from '@hyperlane-xyz/core';
1
+ import { DelayedFlowRouterHookIsm__factory, MailboxClient__factory, Mailbox__factory, ProxyAdmin__factory, } from '@hyperlane-xyz/core';
2
2
  import { buildArtifact as coreBuildArtifact } from '@hyperlane-xyz/core/buildArtifact.js';
3
3
  import { createHookWriter, createIsmWriter, createWarpTokenWriter, validateIsmConfig, } from '@hyperlane-xyz/deploy-sdk';
4
4
  import { ProtocolType } from '@hyperlane-xyz/provider-sdk';
@@ -6,23 +6,27 @@ import { ArtifactState } from '@hyperlane-xyz/provider-sdk/artifact';
6
6
  import { hookConfigToArtifact, } from '@hyperlane-xyz/provider-sdk/hook';
7
7
  import { ismConfigToArtifact, } from '@hyperlane-xyz/provider-sdk/ism';
8
8
  import { DEFAULT_CROSS_COLLATERAL_FEE_ROUTER_KEY, TokenType as ProviderTokenType, warpConfigToArtifact, } from '@hyperlane-xyz/provider-sdk/warp';
9
- import { addressToBytes32, assert, isEVMLike, isNullish, isObjEmpty, mapAllSettled, mustGet, objFilter, objKeys, objMap, promiseObjAll, rootLogger, } from '@hyperlane-xyz/utils';
9
+ import { addressToBytes32, assert, eqAddress, isEVMLike, isNullish, isObjEmpty, isZeroishAddress, mapAllSettled, mustGet, objFilter, objKeys, objMap, promiseObjAll, rootLogger, } from '@hyperlane-xyz/utils';
10
10
  import { ExplorerLicenseType } from '../block-explorer/etherscan.js';
11
11
  import { CCIPContractCache } from '../ccip/utils.js';
12
12
  import { tokenFeeInputToFeeConfig } from '../fee/feeConfigMapping.js';
13
13
  import { EvmHookModule } from '../hook/EvmHookModule.js';
14
- import { hookTreeContainsRateLimited } from '../hook/utils.js';
14
+ import { hookTreeContainsRateLimited, mapHybridHookNodes, } from '../hook/utils.js';
15
15
  import { EvmIsmModule } from '../ism/EvmIsmModule.js';
16
+ import { EvmIsmReader } from '../ism/EvmIsmReader.js';
16
17
  import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js';
18
+ import { IsmType, } from '../ism/types.js';
17
19
  import { altVmChainLookup } from '../metadata/ChainMetadataManager.js';
18
20
  import { resolveRouterMapConfig, } from '../router/types.js';
19
21
  import { EvmWarpModule } from '../token/EvmWarpModule.js';
20
22
  import { MAX_GAS_OVERHEAD, TokenType, gasOverhead } from '../token/config.js';
21
23
  import { hypERC20factories } from '../token/contracts.js';
22
24
  import { HypERC20Deployer, HypERC721Deployer } from '../token/deploy.js';
23
- import { assertTimelockConfigHasNoProxyAdminOwnerOverride, } from '../token/types.js';
24
- import { extractIsmAndHookFactoryAddresses, ismTreeContainsRateLimited, } from '../utils/ism.js';
25
+ import { assertTimelockConfigHasNoProxyAdminOwnerOverride, WarpRouteDeployConfigMailboxRequiredSchema, } from '../token/types.js';
26
+ import { throwIfNotMissingSelector } from '../utils/contract.js';
27
+ import { canonicalizeRemoteIsms, collectHybridIsmNodes, extractIsmAndHookFactoryAddresses, ismTreeContainsRateLimited, mapHybridIsmNodes, resolveDelayedFlowRemoteIsms, setRateLimitedIsmRecipient, } from '../utils/ism.js';
25
28
  import { HyperlaneProxyFactoryDeployer } from './HyperlaneProxyFactoryDeployer.js';
29
+ import { hybridLeafDeployConfig, planWarpRouteHybrids, resolveHybridTrees, } from './warpHybridPlan.js';
26
30
  import { ContractVerifier } from './verify/ContractVerifier.js';
27
31
  const SUPPORTED_ALTVM_TOKEN_TYPES = new Set([
28
32
  TokenType.synthetic,
@@ -168,39 +172,121 @@ class RateLimitedHookERC20Deployer extends HypERC20Deployer {
168
172
  await this.preTransferFn(objMap(contractsMap, (_, contracts) => getRouter(contracts).address));
169
173
  }
170
174
  }
175
+ /**
176
+ * Deploys a WHOLE warp route: its routers, plus the ISM and hook trees they
177
+ * install.
178
+ *
179
+ * Everything decidable from the config is decided before any gas is spent —
180
+ * the full `IsmConfigSchema`/`HookConfigSchema` refinement over both trees, the
181
+ * hybrid hook/ISM pairing (planWarpRouteHybrids), and the route-scoped
182
+ * delayed-flow invariants. The route-scoped ones read the config's chain set as
183
+ * "the route", so they belong to this entry point alone: shrinking the config
184
+ * does not narrow them, it enlarges what counts as outside the route. Use
185
+ * `executeWarpRouteExtensionDeploy` for the chains an extension adds.
186
+ */
171
187
  export async function executeWarpDeploy(warpDeployConfig, multiProvider, altVmSigners, registryAddresses, apiKeys) {
172
188
  assertWarpConfigTimelocksSupportedByProtocols({
173
189
  multiProvider,
174
190
  warpDeployConfig,
175
191
  });
192
+ WarpRouteDeployConfigMailboxRequiredSchema.parse(warpDeployConfig);
193
+ const args = {
194
+ warpDeployConfig,
195
+ multiProvider,
196
+ altVmSigners,
197
+ registryAddresses,
198
+ apiKeys,
199
+ };
200
+ const hybridPlan = planWarpRouteHybrids({ multiProvider, warpDeployConfig });
201
+ assertDelayedFlowRouteCoverage({ multiProvider, warpDeployConfig });
202
+ await assertDelayedFlowMailboxNonces(multiProvider, warpDeployConfig);
203
+ return deployWarpRouteChains(args, hybridPlan);
204
+ }
205
+ /**
206
+ * Deploys the chains an extension adds to an EXISTING route.
207
+ *
208
+ * Same engine as `executeWarpDeploy`, minus the invariants that measure the
209
+ * route by the config's own chain set — the caller holds the full route config
210
+ * and has already checked those (runWarpRouteApply). Everything per chain still
211
+ * runs here, so an extension cannot deploy a chain whose composition can never
212
+ * work.
213
+ *
214
+ * A separate entry point rather than a flag: which validation applies is a
215
+ * property of what the caller is deploying, not something to infer from the
216
+ * shape of the config it happens to pass.
217
+ */
218
+ export async function executeWarpRouteExtensionDeploy(warpDeployConfig, multiProvider, altVmSigners, registryAddresses, apiKeys) {
219
+ assertWarpConfigTimelocksSupportedByProtocols({
220
+ multiProvider,
221
+ warpDeployConfig,
222
+ });
223
+ WarpRouteDeployConfigMailboxRequiredSchema.parse(warpDeployConfig);
224
+ const args = {
225
+ warpDeployConfig,
226
+ multiProvider,
227
+ altVmSigners,
228
+ registryAddresses,
229
+ apiKeys,
230
+ };
231
+ const hybridPlan = planWarpRouteHybrids({ multiProvider, warpDeployConfig });
232
+ assertDelayedFlowLegCoverage(warpDeployConfig);
233
+ await assertDelayedFlowMailboxNonces(multiProvider, warpDeployConfig);
234
+ return deployWarpRouteChains(args, hybridPlan);
235
+ }
236
+ /**
237
+ * The staged deploy engine shared by both entry points. Called only after the
238
+ * whole config has been parsed and validated, so nothing here re-decides
239
+ * whether the route is deployable — it just deploys it in an order that never
240
+ * leaves an unsafe route reachable:
241
+ *
242
+ * 1. routers are deployed BARE on every chain that installs a hybrid: the
243
+ * hybrid constructors read the live router's `token()` / `mailbox()`, so the
244
+ * router has to exist first, and it must not be usable in the meantime;
245
+ * 2. the hybrid leaf is deployed exactly once per chain and bound to that
246
+ * router;
247
+ * 3. DELAYED_FLOW_ROUTER counterparts are enrolled once every chain's instance
248
+ * address is known, while the deployer still owns the fresh instances;
249
+ * 4. the leaf address replaces the hybrid node in BOTH trees, and the remaining
250
+ * parents are deployed through the ordinary modules;
251
+ * 5. hooks are installed route-wide, then read back, and only then are the ISMs
252
+ * installed — a router whose hybrid ISM is live before its hook is one that
253
+ * rejects every delivery, because nothing sent the preverification.
254
+ *
255
+ * Router enrollment and ownership transfer happen after this returns
256
+ * (enrollCrossChainRouters), so no transfer can be dispatched through a
257
+ * half-wired router.
258
+ */
259
+ async function deployWarpRouteChains({ warpDeployConfig, multiProvider, altVmSigners, registryAddresses, apiKeys, }, hybridPlan) {
176
260
  const contractVerifier = new ContractVerifier(multiProvider, apiKeys, coreBuildArtifact, ExplorerLicenseType.MIT);
177
261
  const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider, contractVerifier);
178
- // Capture ISM configs that contain a RATE_LIMITED node before resolveWarpIsmAndHook
179
- // runs that function replaces each chain's ISM field with the deployed address,
180
- // but RATE_LIMITED ISMs (and any composite ISM containing one) are skipped there
181
- // because the constructor requires the token address (recipient), which doesn't
182
- // exist yet. They are wired inside TokenDeployer.deploy() before ownership is
183
- // transferred so that setInterchainSecurityModule succeeds regardless of config.owner.
184
- const rateLimitedSnapshot = {};
262
+ // RATE_LIMITED ISM trees are deployed after the router exists: the
263
+ // constructor takes the token as `recipient`. Unlike the hybrids they are
264
+ // only an ISM, so TokenDeployer wires them itself, before it hands ownership
265
+ // over.
266
+ const rateLimitedIsmSnapshot = {};
185
267
  for (const [chain, config] of Object.entries(warpDeployConfig)) {
268
+ // Hybrid chains deploy the whole resolved parent tree in the staged pass;
269
+ // passing the same tree to TokenDeployer would deploy it a second time.
270
+ if (hybridPlan[chain])
271
+ continue;
186
272
  if (typeof config.interchainSecurityModule !== 'object')
187
273
  continue;
188
274
  const ism = config.interchainSecurityModule;
189
275
  if (!ismTreeContainsRateLimited(ism))
190
276
  continue;
191
277
  const protocol = multiProvider.getProtocol(chain);
192
- assert(protocol === ProtocolType.Ethereum || protocol === ProtocolType.Tron, `RateLimitedIsm is only supported on Ethereum and Tron chains, but chain ${chain} has protocol ${protocol}`);
193
- // Store the full ISM tree as-is; recipient + owner defaults are applied
194
- // uniformly in setRateLimitedIsms via setRateLimitedIsmRecipient.
195
- rateLimitedSnapshot[chain] = ism;
278
+ assert(protocol === ProtocolType.Ethereum || protocol === ProtocolType.Tron, `${IsmType.RATE_LIMITED} is only supported on Ethereum and Tron chains, but chain ${chain} has protocol ${protocol}`);
279
+ rateLimitedIsmSnapshot[chain] = ism;
196
280
  }
197
281
  // Hooks containing RATE_LIMITED need the token router address as sender, so they are deferred
198
282
  // until after token deployment. resolveWarpIsmAndHook populates this map (EVM/Tron only) and
199
283
  // returns undefined for those hooks, causing them to be set later via setHook().
200
284
  const rateLimitedHookSnapshots = {};
201
285
  // For each chain in WarpRouteConfig, deploy each Ism Factory, if it's not in the registry
202
- // Then return a modified config with the ism and/or hook address as a string
203
- const modifiedConfig = await resolveWarpIsmAndHook(warpDeployConfig, multiProvider, altVmSigners, registryAddresses, ismFactoryDeployer, contractVerifier, rateLimitedHookSnapshots);
286
+ // Then return a modified config with the ism and/or hook address as a string.
287
+ // Chains that install a hybrid get NEITHER surface here: their router is
288
+ // deployed bare and wired in the staged pass below.
289
+ const modifiedConfig = await resolveWarpIsmAndHook(warpDeployConfig, multiProvider, altVmSigners, registryAddresses, ismFactoryDeployer, contractVerifier, rateLimitedHookSnapshots, hybridPlan);
204
290
  // Initialize with unsupported chains so that they are enrolled
205
291
  let deployedContracts = objMap(objFilter(warpDeployConfig, (_chain, config) => !!config.foreignDeployment), (chain, config) => {
206
292
  assert(config.foreignDeployment, `Expected foreignDeployment field to be defined on ${chain} after filtering`);
@@ -229,13 +315,14 @@ export async function executeWarpDeploy(warpDeployConfig, multiProvider, altVmSi
229
315
  const chainSnapshots = objFilter(rateLimitedHookSnapshots, (chain, _v) => chain in deployedTokens);
230
316
  if (isObjEmpty(chainSnapshots))
231
317
  return;
232
- const deployedHooks = await deployAndWireRateLimitedHooks(chainSnapshots, deployedTokens, multiProvider, contractVerifier);
233
- for (const [chain, hookAddress] of Object.entries(deployedHooks)) {
234
- warpDeployConfig[chain].hook = hookAddress;
235
- }
318
+ // The deployed address is deliberately NOT written back into
319
+ // warpDeployConfig: the caller persists that object to the
320
+ // registry, which stores declarative config, not the runtime
321
+ // addresses of this run.
322
+ await deployAndWireRateLimitedHooks(chainSnapshots, deployedTokens, multiProvider, contractVerifier);
236
323
  });
237
324
  const chainSet = new Set(Object.keys(protocolSpecificConfig));
238
- const rateLimitedForBatch = objFilter(rateLimitedSnapshot, (_chain, _ismConfig) => chainSet.has(_chain));
325
+ const deferredForBatch = objFilter(rateLimitedIsmSnapshot, (_chain, _ismConfig) => chainSet.has(_chain));
239
326
  // Deploy as the deployer signer (intermediate owner), mirroring the
240
327
  // AltVM branch below. Cross-chain router enrollment runs after deploy
241
328
  // in enrollCrossChainRouters, submitted by the deployer key, and hands
@@ -253,7 +340,12 @@ export async function executeWarpDeploy(warpDeployConfig, multiProvider, altVmSi
253
340
  ? config.owner
254
341
  : await multiProvider.getSigner(chain).getAddress(),
255
342
  })));
256
- const evmContracts = await deployer.deploy(intermediateOwnerConfig, rateLimitedForBatch);
343
+ const deferRouterEnrollment = Object.keys(protocolSpecificConfig).some((chain) => chain in hybridPlan);
344
+ const evmContracts = warpDeployConfig.isNft
345
+ ? await deployer.deploy(intermediateOwnerConfig)
346
+ : await deployer.deploy(intermediateOwnerConfig, deferredForBatch, {
347
+ deferRouterEnrollment,
348
+ });
257
349
  deployedContracts = {
258
350
  ...deployedContracts,
259
351
  ...objMap(evmContracts, (_, contracts) => getRouter(contracts).address),
@@ -290,8 +382,185 @@ export async function executeWarpDeploy(warpDeployConfig, multiProvider, altVmSi
290
382
  }
291
383
  }
292
384
  }
385
+ await wireHybridHookIsms({
386
+ hybridPlan,
387
+ multiProvider,
388
+ registryAddresses,
389
+ contractVerifier,
390
+ warpDeployConfig,
391
+ deployedContracts,
392
+ });
293
393
  return deployedContracts;
294
394
  }
395
+ /**
396
+ * Stages 2 to 5 of the deploy: turn bare routers into fully wired hybrid legs.
397
+ *
398
+ * Split out of the protocol loop above because every stage is a route-wide
399
+ * barrier — stage N must have completed on EVERY chain before stage N+1 starts
400
+ * on ANY of them. Enrollment needs all instance addresses; the hook install
401
+ * needs every counterpart enrolled; the ISM install needs every hook live.
402
+ */
403
+ async function wireHybridHookIsms({ hybridPlan, multiProvider, registryAddresses, contractVerifier, warpDeployConfig, deployedContracts, }) {
404
+ if (isObjEmpty(hybridPlan))
405
+ return;
406
+ const ismFactory = HyperlaneIsmFactory.fromAddressesMap(registryAddresses, multiProvider, undefined, contractVerifier);
407
+ // Stage 2 — one instance per chain, bound to the router deployed above.
408
+ // deployInternal rather than deploy: the leaf on its own is exactly the shape
409
+ // IsmConfigSchema rejects (a hybrid outside its mandatory aggregation), and
410
+ // the composition it would re-check was already validated over the whole tree
411
+ // by planWarpRouteHybrids.
412
+ const hybridAddresses = await promiseObjAll(objMap(hybridPlan, async (chain, entry) => {
413
+ const router = mustGet(deployedContracts, chain);
414
+ const chainAddresses = mustGet(registryAddresses, chain);
415
+ const leaf = hybridLeafDeployConfig({
416
+ entry,
417
+ warpRouter: router,
418
+ deployerAddress: await multiProvider.getSigner(chain).getAddress(),
419
+ });
420
+ rootLogger.info(`Deploying ${leaf.type} on ${chain} for router ${router}`);
421
+ const deployed = await ismFactory.deployInternal({
422
+ destination: chain,
423
+ config: leaf,
424
+ mailbox: chainAddresses.mailbox,
425
+ });
426
+ return deployed.address;
427
+ }));
428
+ // Stage 3 — pair the DELAYED_FLOW_ROUTER instances with each other while the
429
+ // deployer still owns them. Ownership moves to the configured owner in the
430
+ // route's final pass (enrollCrossChainRouters), which is also what re-runs
431
+ // this reconciliation for a route that was only partly enrolled.
432
+ await enrollDelayedFlowInstances({
433
+ hybridPlan,
434
+ multiProvider,
435
+ hybridAddresses,
436
+ });
437
+ // Stage 4 — substitute the shared address into both trees and deploy whatever
438
+ // parents remain through the ordinary modules.
439
+ const wiring = await promiseObjAll(objMap(hybridPlan, async (chain, entry) => {
440
+ const hybrid = mustGet(hybridAddresses, chain);
441
+ const router = mustGet(deployedContracts, chain);
442
+ const chainAddresses = mustGet(registryAddresses, chain);
443
+ const resolved = resolveHybridTrees(entry, hybrid);
444
+ const resolvedIsm = setRateLimitedIsmRecipient(resolved.ism, router, mustGet(warpDeployConfig, chain).owner);
445
+ const ccipContractCache = new CCIPContractCache(registryAddresses);
446
+ const proxyFactoryFactories = extractIsmAndHookFactoryAddresses(chainAddresses);
447
+ const ism = typeof resolvedIsm === 'string'
448
+ ? resolvedIsm
449
+ : await deployIsmTree({
450
+ chain,
451
+ multiProvider,
452
+ chainAddresses,
453
+ config: resolvedIsm,
454
+ ccipContractCache,
455
+ contractVerifier,
456
+ proxyFactoryFactories,
457
+ });
458
+ const hook = typeof resolved.hook === 'string'
459
+ ? resolved.hook
460
+ : await deployHookTree({
461
+ chain,
462
+ multiProvider,
463
+ chainAddresses,
464
+ config: resolved.hook,
465
+ ccipContractCache,
466
+ contractVerifier,
467
+ proxyFactoryFactories,
468
+ proxyAdminAddress: mustGet(warpDeployConfig, chain).proxyAdmin
469
+ ?.address,
470
+ });
471
+ return { router, hybrid, ism, hook };
472
+ }));
473
+ // Stage 5 — hooks route-wide, barrier, then ISMs route-wide.
474
+ await installHybridSurfaces({ multiProvider, wiring });
475
+ }
476
+ async function deployIsmTree({ chain, multiProvider, chainAddresses, config, ccipContractCache, contractVerifier, proxyFactoryFactories, }) {
477
+ const module = await EvmIsmModule.create({
478
+ chain,
479
+ mailbox: chainAddresses.mailbox,
480
+ multiProvider,
481
+ proxyFactoryFactories,
482
+ config,
483
+ ccipContractCache,
484
+ contractVerifier,
485
+ });
486
+ const { deployedIsm } = module.serialize();
487
+ assert(deployedIsm, `Failed to deploy the ISM tree on ${chain}`);
488
+ return deployedIsm;
489
+ }
490
+ async function deployHookTree({ chain, multiProvider, chainAddresses, config, ccipContractCache, contractVerifier, proxyFactoryFactories, proxyAdminAddress, }) {
491
+ const resolvedProxyAdmin = proxyAdminAddress ??
492
+ (await multiProvider.handleDeploy(chain, new ProxyAdmin__factory(), []))
493
+ .address;
494
+ const module = await EvmHookModule.create({
495
+ chain,
496
+ multiProvider,
497
+ coreAddresses: {
498
+ mailbox: chainAddresses.mailbox,
499
+ proxyAdmin: resolvedProxyAdmin,
500
+ },
501
+ config,
502
+ ccipContractCache,
503
+ contractVerifier,
504
+ proxyFactoryFactories,
505
+ });
506
+ const { deployedHook } = module.serialize();
507
+ assert(deployedHook, `Failed to deploy the hook tree on ${chain}`);
508
+ return deployedHook;
509
+ }
510
+ /**
511
+ * Enrolls every DELAYED_FLOW_ROUTER instance of the route with every other one.
512
+ *
513
+ * Runs once all instance addresses exist and while the deployer still owns them
514
+ * (`hybridLeafDeployConfig` keeps DFR ownership with the deployer for exactly
515
+ * this reason), so a fresh route needs no owner-gated batch. Route-derived
516
+ * peers override configured entries for the same chains; configured external
517
+ * peers remain available for unusual topologies.
518
+ */
519
+ async function enrollDelayedFlowInstances({ hybridPlan, multiProvider, hybridAddresses, }) {
520
+ const delayedChains = Object.keys(hybridPlan).filter((chain) => hybridPlan[chain].node.type === IsmType.DELAYED_FLOW_ROUTER);
521
+ if (delayedChains.length === 0)
522
+ return;
523
+ await Promise.all(delayedChains.map(async (chain) => {
524
+ const node = hybridPlan[chain].node;
525
+ assert(node.type === IsmType.DELAYED_FLOW_ROUTER, `Unreachable: ${chain} was filtered as a delayed-flow chain`);
526
+ const ismAddress = mustGet(hybridAddresses, chain);
527
+ const derived = Object.fromEntries(delayedChains
528
+ .filter((otherChain) => otherChain !== chain)
529
+ .map((otherChain) => [
530
+ otherChain,
531
+ addressToBytes32(mustGet(hybridAddresses, otherChain)),
532
+ ]));
533
+ const remoteIsms = resolveDelayedFlowRemoteIsms(node.remoteIsms, derived, `${IsmType.DELAYED_FLOW_ROUTER} ${ismAddress} on ${chain}`, multiProvider);
534
+ if (!remoteIsms || isObjEmpty(remoteIsms))
535
+ return;
536
+ const domains = [];
537
+ const routers = [];
538
+ for (const [remoteChain, router] of Object.entries(remoteIsms)) {
539
+ domains.push(multiProvider.getDomainId(remoteChain));
540
+ routers.push(router);
541
+ }
542
+ rootLogger.info(`Enrolling ${domains.length} counterpart(s) on ${IsmType.DELAYED_FLOW_ROUTER} ${ismAddress} (${chain})`);
543
+ const instance = DelayedFlowRouterHookIsm__factory.connect(ismAddress, multiProvider.getSigner(chain));
544
+ await multiProvider.handleTx(chain, instance.enrollRemoteRouters(domains, routers, multiProvider.getTransactionOverrides(chain)));
545
+ }));
546
+ }
547
+ /** Installs hooks route-wide before installing the corresponding ISMs. */
548
+ async function installHybridSurfaces({ multiProvider, wiring, }) {
549
+ await promiseObjAll(objMap(wiring, async (chain, { router, hook }) => {
550
+ rootLogger.info(`Installing hook ${hook} on router ${router} (${chain})`);
551
+ await multiProvider.sendTransaction(chain, {
552
+ to: router,
553
+ data: MailboxClient__factory.createInterface().encodeFunctionData('setHook', [hook]),
554
+ });
555
+ }));
556
+ await promiseObjAll(objMap(wiring, async (chain, { router, ism }) => {
557
+ rootLogger.info(`Installing ISM ${ism} on router ${router} (${chain})`);
558
+ await multiProvider.sendTransaction(chain, {
559
+ to: router,
560
+ data: MailboxClient__factory.createInterface().encodeFunctionData('setInterchainSecurityModule', [ism]),
561
+ });
562
+ }));
563
+ }
295
564
  async function deployAndWireRateLimitedHooks(snapshots, deployedTokens, multiProvider, contractVerifier) {
296
565
  return promiseObjAll(objMap(snapshots, async (chain, { hookConfig, chainAddresses, ccipContractCache, proxyAdminAddress }) => {
297
566
  const tokenAddress = mustGet(deployedTokens, chain);
@@ -321,13 +590,26 @@ async function deployAndWireRateLimitedHooks(snapshots, deployedTokens, multiPro
321
590
  return deployedHook;
322
591
  }));
323
592
  }
324
- async function resolveWarpIsmAndHook(warpConfig, multiProvider, altVmSigners, registryAddresses, ismFactoryDeployer, contractVerifier, rateLimitedHookSnapshots) {
593
+ async function resolveWarpIsmAndHook(warpConfig, multiProvider, altVmSigners, registryAddresses, ismFactoryDeployer, contractVerifier, rateLimitedHookSnapshots, hybridPlan) {
325
594
  return promiseObjAll(objMap(warpConfig, async (chain, config) => {
326
595
  const ccipContractCache = new CCIPContractCache(registryAddresses);
327
596
  const chainAddresses = registryAddresses[chain];
328
597
  if (!chainAddresses) {
329
598
  throw new Error(`Registry factory addresses not found for ${chain}.`);
330
599
  }
600
+ // A chain whose composition includes a hybrid deploys its router BARE.
601
+ // Both surfaces reference one instance whose constructor reads the live
602
+ // router, so neither tree can be built yet — and leaving the router
603
+ // without an ISM or hook until both are ready is what keeps a
604
+ // half-wired route from being usable.
605
+ if (hybridPlan[chain]) {
606
+ rootLogger.info(`Deploying ${chain} router bare: its ${hybridPlan[chain].node.type} is wired after the router exists`);
607
+ return {
608
+ ...config,
609
+ interchainSecurityModule: undefined,
610
+ hook: undefined,
611
+ };
612
+ }
331
613
  const ism = await createWarpIsm({
332
614
  ccipContractCache,
333
615
  chain,
@@ -371,13 +653,14 @@ async function createWarpIsm({ ccipContractCache, chain, chainAddresses, multiPr
371
653
  rootLogger.info(`Config Ism is ${!interchainSecurityModule ? 'empty' : interchainSecurityModule}, skipping deployment.`);
372
654
  return interchainSecurityModule;
373
655
  }
374
- // RateLimitedIsm has a chicken-and-egg problem: the constructor requires the
375
- // token (recipient) address, but ISMs are deployed here — before the token exists.
376
- // We skip any ISM tree that contains a RATE_LIMITED node and deploy it later in
377
- // setRateLimitedIsms() (after the token is deployed), then wire it up via
378
- // setInterchainSecurityModule().
656
+ // A RATE_LIMITED ISM has a chicken-and-egg problem: its constructor requires
657
+ // the token address as `recipient`, but ISMs are deployed here — before the
658
+ // token exists. Skip any tree containing one; TokenDeployer.setRateLimitedIsms
659
+ // deploys it after the router and wires it with setInterchainSecurityModule()
660
+ // while the deployer still owns the router. (Hybrid hook/ISM trees never
661
+ // reach this function — resolveWarpIsmAndHook returns their chains bare.)
379
662
  if (ismTreeContainsRateLimited(interchainSecurityModule)) {
380
- rootLogger.info(`Skipping ISM deployment for ${chain} (contains RateLimitedIsm), will deploy after token.`);
663
+ rootLogger.info(`Skipping ISM deployment for ${chain} (contains a ${IsmType.RATE_LIMITED} ISM), will deploy after token.`);
381
664
  return undefined;
382
665
  }
383
666
  rootLogger.info(`Loading registry factory addresses for ${chain}...`);
@@ -475,12 +758,361 @@ async function createWarpHook({ ccipContractCache, chain, chainAddresses, multiP
475
758
  }
476
759
  }
477
760
  }
761
+ /**
762
+ * The DELAYED_FLOW_ROUTER nodes a chain's warp config installs.
763
+ *
764
+ * An ISM configured as a bare address cannot be inspected, so it reads as "no
765
+ * instance"; assertDelayedFlowLegCoverage turns that into an explicit error
766
+ * instead of a silent pass.
767
+ */
768
+ function delayedFlowIsmNodes(config) {
769
+ if (typeof config.interchainSecurityModule !== 'object')
770
+ return [];
771
+ return collectHybridIsmNodes(config.interchainSecurityModule).filter((node) => node.type === IsmType.DELAYED_FLOW_ROUTER);
772
+ }
773
+ /** True when a chain's warp config installs a DELAYED_FLOW_ROUTER instance. */
774
+ function configInstallsDelayedFlowIsm(config) {
775
+ return delayedFlowIsmNodes(config).length > 0;
776
+ }
777
+ /** The chains of a config whose ISM tree installs a DELAYED_FLOW_ROUTER. */
778
+ function delayedFlowChains(warpDeployConfig) {
779
+ return Object.keys(warpDeployConfig).filter((chain) => configInstallsDelayedFlowIsm(warpDeployConfig[chain]));
780
+ }
781
+ /** Domain id of every chain the route deploys, asserted resolvable. */
782
+ function resolveRouteDomains(multiProvider, warpDeployConfig) {
783
+ const routeDomains = new Map();
784
+ for (const chain of Object.keys(warpDeployConfig)) {
785
+ const domain = multiProvider.tryGetDomainId(chain);
786
+ assert(domain !== null, `No chain metadata for ${chain}, which this ${IsmType.DELAYED_FLOW_ROUTER} route deploys — add it to the registry`);
787
+ routeDomains.set(chain, domain);
788
+ }
789
+ return routeDomains;
790
+ }
791
+ /**
792
+ * The domains a chain's DELAYED_FLOW_ROUTER nodes enroll through `remoteIsms`.
793
+ *
794
+ * Resolved through the same canonicalization the deploy, update and check
795
+ * paths use (canonicalizeRemoteIsms), so this preflight accepts exactly the
796
+ * keys they accept: a key it cannot resolve, or two keys naming one chain,
797
+ * throw here — before any contract is deployed — instead of passing the
798
+ * preflight and failing mid-deploy.
799
+ */
800
+ function configuredRemoteIsmDomains(multiProvider, chain, config) {
801
+ const domains = new Set();
802
+ for (const node of delayedFlowIsmNodes(config)) {
803
+ const canonical = canonicalizeRemoteIsms(node.remoteIsms ?? {}, multiProvider, `${IsmType.DELAYED_FLOW_ROUTER} on ${chain}`);
804
+ for (const chainName of Object.keys(canonical)) {
805
+ domains.add(multiProvider.getDomainId(chainName));
806
+ }
807
+ }
808
+ return domains;
809
+ }
810
+ /**
811
+ * Rejects a `remoteRouters` entry naming a chain outside the deploy config
812
+ * unless the chain's DelayedFlowRouterHookIsm enrolls it through `remoteIsms`.
813
+ *
814
+ * `remoteRouters` legs are as connected as the deployed ones:
815
+ * `TimelockRouter.postDispatch` dispatches the preverification to the
816
+ * transfer's destination domain, and `Router._mustHaveRemoteRouter` reverts
817
+ * when the instance has no counterpart enrolled there. The automatic pairing
818
+ * only covers the chains of the deploy config (deriveDelayedFlowEnrollmentTargets),
819
+ * so an out-of-config leg strands transfers exactly like an uncovered one.
820
+ *
821
+ * `remoteIsms` is the escape hatch for pairings the route cannot derive. The
822
+ * route-derived peers are merged into it separately.
823
+ */
824
+ function assertDelayedFlowRemoteRouterCoverage(multiProvider, warpDeployConfig, routeDomains) {
825
+ const deployedDomains = new Set(routeDomains.values());
826
+ for (const [chain, config] of Object.entries(warpDeployConfig)) {
827
+ const enrolledDomains = configuredRemoteIsmDomains(multiProvider, chain, config);
828
+ for (const key of Object.keys(config.remoteRouters ?? {})) {
829
+ // Resolved through the registry for BOTH key forms (chain name and
830
+ // domain id): the only remedy this check can suggest for an unenrolled
831
+ // domain is listing it under 'remoteIsms', and canonicalizeRemoteIsms
832
+ // rejects a key it cannot resolve to a registered chain. Taking a
833
+ // numeric key at face value here would send the operator round in a
834
+ // circle between the two checks.
835
+ const domain = multiProvider.tryGetDomainId(key);
836
+ assert(domain !== null, `No chain metadata for ${key}, which ${chain} names in 'remoteRouters' — this route's ${IsmType.DELAYED_FLOW_ROUTER} legs are resolved through the registry, so add the chain to it or remove the entry`);
837
+ const remoteName = multiProvider.getChainName(domain);
838
+ assert(deployedDomains.has(domain) || enrolledDomains.has(domain), `${chain} names ${remoteName} (domain ${domain}) in 'remoteRouters', but that chain is not part of this route and ${chain}'s ${IsmType.DELAYED_FLOW_ROUTER} does not enroll it. Transfers to it dispatch a preverification through that instance, which reverts because no counterpart is enrolled for the domain. Add ${remoteName} to the route, drop it from 'remoteRouters', or list its ${IsmType.DELAYED_FLOW_ROUTER} instance under 'remoteIsms' on ${chain}.`);
839
+ }
840
+ }
841
+ }
842
+ /**
843
+ * Rejects a config that carries a DelayedFlowRouterHookIsm on only some of its
844
+ * legs, before any on-chain work happens.
845
+ *
846
+ * The instances are peers: `TimelockRouter.postDispatch` dispatches a
847
+ * preverification message to the transfer's destination domain, and
848
+ * `Router._mustHaveRemoteRouter` reverts when that domain has no counterpart
849
+ * enrolled. So a DFR origin paired with a plain destination reverts at
850
+ * quote/dispatch time, and a plain origin paired with a DFR destination never
851
+ * sends the preverification the destination waits for — the transfer is
852
+ * stranded either way. A foreignDeployment leg is rejected outright: its
853
+ * instance is outside this deployment's control, so it can never be enrolled.
854
+ *
855
+ * Safe on any subset of a route: dropping a chain drops it from both sides of
856
+ * every comparison here, so a topology accepted as a whole is still accepted
857
+ * chain by chain. The checks whose verdict depends on WHICH chains the config
858
+ * contains live in assertDelayedFlowRouteCoverage.
859
+ *
860
+ * Checked against the config rather than mid-apply on purpose: an extension
861
+ * applies chain by chain, so a mid-apply check would fire on a route that is
862
+ * merely part-deployed.
863
+ */
864
+ function assertDelayedFlowLegCoverage(warpDeployConfig) {
865
+ const chains = Object.keys(warpDeployConfig);
866
+ const covered = delayedFlowChains(warpDeployConfig);
867
+ if (covered.length === 0)
868
+ return;
869
+ const foreignDeployments = chains.filter((chain) => !!warpDeployConfig[chain].foreignDeployment);
870
+ assert(foreignDeployments.length === 0, `${IsmType.DELAYED_FLOW_ROUTER} is configured on ${covered.join(', ')}, but ${foreignDeployments.join(', ')} is a foreignDeployment whose instance cannot be deployed or enrolled by this route. Every leg of a delayed-flow route must be deployed by it.`);
871
+ const uncovered = chains.filter((chain) => !configInstallsDelayedFlowIsm(warpDeployConfig[chain]));
872
+ assert(uncovered.length === 0, `${IsmType.DELAYED_FLOW_ROUTER} is configured on ${covered.join(', ')} but not on ${uncovered.join(', ')}. Transfers between a delayed-flow leg and a plain leg revert on both sides, so every chain of the route must configure one (an ISM given as an address cannot be inspected — declare it inline).`);
873
+ }
874
+ /**
875
+ * Whole-route coverage: every leg carries an instance
876
+ * (assertDelayedFlowLegCoverage), and a `remoteRouters` entry naming a chain
877
+ * outside the route has to enroll that counterpart explicitly
878
+ * (assertDelayedFlowRemoteRouterCoverage).
879
+ *
880
+ * Correct only against the FULL route config. Shrinking the config enlarges
881
+ * what counts as outside the route, so running this on a subset — the single
882
+ * chain `warp apply` deploys per extension, say — rejects `remoteRouters`
883
+ * entries naming the very legs that chain is joining.
884
+ */
885
+ export function assertDelayedFlowRouteCoverage({ multiProvider, warpDeployConfig, }) {
886
+ assertDelayedFlowLegCoverage(warpDeployConfig);
887
+ if (delayedFlowChains(warpDeployConfig).length === 0)
888
+ return;
889
+ const routeDomains = resolveRouteDomains(multiProvider, warpDeployConfig);
890
+ assertDelayedFlowRemoteRouterCoverage(multiProvider, warpDeployConfig, routeDomains);
891
+ }
892
+ /**
893
+ * Rejects a delayed-flow leg whose mailbox has never dispatched a message.
894
+ *
895
+ * DelayedFlowRouterHookIsm initialises `lastCreditedNonce` to 0 and rejects
896
+ * `nonce <= lastCreditedNonce`, so a dispatch carrying mailbox nonce 0 can
897
+ * never be credited: the first transfer out of a brand-new mailbox reverts
898
+ * inside postDispatch and the route strands it. Deploying onto such a chain is
899
+ * rejected instead of producing a route whose first transfer is guaranteed to
900
+ * fail. Per chain, so it holds on any subset of a route.
901
+ */
902
+ async function assertDelayedFlowMailboxNonces(multiProvider, warpDeployConfig) {
903
+ const mailboxNonces = await Promise.all(delayedFlowChains(warpDeployConfig).map(async (chain) => {
904
+ const protocol = multiProvider.getProtocol(chain);
905
+ assert(protocol === ProtocolType.Ethereum || protocol === ProtocolType.Tron, `${IsmType.DELAYED_FLOW_ROUTER} is configured on ${chain}, whose protocol is ${protocol} — the contract only exists on Ethereum and Tron chains`);
906
+ const { mailbox } = warpDeployConfig[chain];
907
+ assert(mailbox, `Missing mailbox address for ${chain}`);
908
+ const nonce = await Mailbox__factory.connect(mailbox, multiProvider.getProvider(chain)).nonce();
909
+ return { chain, mailbox, nonce };
910
+ }));
911
+ for (const { chain, mailbox, nonce } of mailboxNonces) {
912
+ assert(nonce > 0, `Mailbox ${mailbox} on ${chain} has never dispatched a message, and ${IsmType.DELAYED_FLOW_ROUTER} cannot credit a dispatch carrying mailbox nonce 0 (its lastCreditedNonce starts at 0 and it rejects nonce <= lastCreditedNonce), so the route's first transfer out of ${chain} would revert. Dispatch any other message through that mailbox first.`);
913
+ }
914
+ }
915
+ /**
916
+ * The full delayed-flow preconditions for a whole route, checked before any
917
+ * on-chain work: assertDelayedFlowRouteCoverage — which adds the route-scoped
918
+ * external `remoteRouters` check to the leg coverage above — and the
919
+ * mailbox nonce guard.
920
+ *
921
+ * Call this with every chain of the route. The CLI entry points do
922
+ * (runWarpRouteDeploy, runWarpRouteApply); executeWarpDeploy cannot, because
923
+ * an extension reaches it one chain at a time.
924
+ */
925
+ export async function assertDelayedFlowRoutePreconditions({ multiProvider, warpDeployConfig, }) {
926
+ assertDelayedFlowRouteCoverage({ multiProvider, warpDeployConfig });
927
+ await assertDelayedFlowMailboxNonces(multiProvider, warpDeployConfig);
928
+ }
929
+ /**
930
+ * The hybrid hook/ISM instance nested inside an ISM tree deployed on chain, or
931
+ * undefined when the tree holds none.
932
+ *
933
+ * The ISM surface is where the instance is always reachable: the hook surface
934
+ * may install an aggregation the instance merely sits inside, whereas the ISM
935
+ * tree is what the schema constrains and what `EvmIsmReader` walks, and derived
936
+ * nodes carry their address.
937
+ */
938
+ async function readHybridIsmInTree(multiProvider, chain, ismTreeAddress) {
939
+ if (isZeroishAddress(ismTreeAddress))
940
+ return undefined;
941
+ const derived = await new EvmIsmReader(multiProvider, chain).deriveIsmConfig(ismTreeAddress);
942
+ const nodes = collectHybridIsmNodes(derived);
943
+ if (nodes.length === 0)
944
+ return undefined;
945
+ assert(nodes.length === 1, `Expected at most one hybrid hook/ISM instance in the ISM tree at ${ismTreeAddress} on ${chain}, found ${nodes.length}`);
946
+ const node = nodes[0];
947
+ assert('address' in node && typeof node.address === 'string', `Derived hybrid hook/ISM node on ${chain} is missing its address`);
948
+ return node.address;
949
+ }
950
+ /**
951
+ * Resolves the shared hybrid instance behind each chain's ISM tree.
952
+ *
953
+ * Callers pass whichever tree is authoritative for them: the address a run has
954
+ * just deployed but not yet installed, or the tree already on the router.
955
+ * Nothing here guesses between the two — a caller that knows both states knows
956
+ * which one it means.
957
+ */
958
+ export async function resolveHybridIsmAddresses(multiProvider, ismTreeAddresses) {
959
+ const resolved = {};
960
+ await Promise.all(Object.entries(ismTreeAddresses).map(async ([chain, treeAddress]) => {
961
+ const hybrid = await readHybridIsmInTree(multiProvider, chain, treeAddress);
962
+ if (hybrid)
963
+ resolved[chain] = hybrid;
964
+ }));
965
+ return resolved;
966
+ }
967
+ /**
968
+ * Resolves the hybrid instance installed on each router, by reading the ISM the
969
+ * router currently points at. Used by callers holding only routers, such as
970
+ * `warp apply` on chains it is not re-deploying.
971
+ */
972
+ export async function readInstalledHybridIsmAddresses(multiProvider, routers) {
973
+ const ismTrees = {};
974
+ await Promise.all(Object.entries(routers).map(async ([chain, router]) => {
975
+ const protocol = multiProvider.getProtocol(chain);
976
+ if (protocol !== ProtocolType.Ethereum &&
977
+ protocol !== ProtocolType.Tron) {
978
+ return;
979
+ }
980
+ ismTrees[chain] = await MailboxClient__factory.connect(router, multiProvider.getProvider(chain)).interchainSecurityModule();
981
+ }));
982
+ return resolveHybridIsmAddresses(multiProvider, ismTrees);
983
+ }
984
+ /**
985
+ * Pairs the route's DelayedFlowRouterHookIsm instances so each one's enrollment
986
+ * transactions can name every other one.
987
+ *
988
+ * `hybridIsmAddresses` is supplied by the caller (resolveHybridIsmAddresses /
989
+ * readInstalledHybridIsmAddresses) rather than re-derived here: which tree is
990
+ * authoritative differs per caller, and a chain whose instance the caller could
991
+ * not resolve is skipped rather than silently paired against a stale one. The
992
+ * config node is carried through as `userNode` so configured external
993
+ * `remoteIsms` are merged with the route-derived pairing.
994
+ */
995
+ export async function deriveDelayedFlowEnrollmentTargets(multiProvider,
996
+ // Mailbox-independent: the pairing is read from the instances themselves, so
997
+ // callers that only hold a registry deploy config can use it too.
998
+ warpDeployConfig, deployedContracts, hybridIsmAddresses) {
999
+ const resolvedHybridAddresses = hybridIsmAddresses ??
1000
+ (await readInstalledHybridIsmAddresses(multiProvider, deployedContracts));
1001
+ const targets = {};
1002
+ for (const [chain, config] of Object.entries(warpDeployConfig)) {
1003
+ if (config.foreignDeployment)
1004
+ continue;
1005
+ if (typeof config.interchainSecurityModule !== 'object')
1006
+ continue;
1007
+ const protocol = multiProvider.getProtocol(chain);
1008
+ if (protocol !== ProtocolType.Ethereum && protocol !== ProtocolType.Tron) {
1009
+ continue;
1010
+ }
1011
+ const delayedNodes = collectHybridIsmNodes(config.interchainSecurityModule).filter((node) => node.type === IsmType.DELAYED_FLOW_ROUTER);
1012
+ if (delayedNodes.length === 0)
1013
+ continue;
1014
+ assert(delayedNodes.length === 1, `Expected exactly one DELAYED_FLOW_ROUTER node in the ISM tree on ${chain}, found ${delayedNodes.length}`);
1015
+ const tokenAddress = deployedContracts[chain];
1016
+ assert(tokenAddress, `No deployed token found for ${chain}`);
1017
+ const provider = multiProvider.getProvider(chain);
1018
+ const ismAddress = resolvedHybridAddresses[chain];
1019
+ assert(ismAddress, `${chain} declares a ${IsmType.DELAYED_FLOW_ROUTER} but no instance was resolved for it — the instance must be deployed and reachable through the router's ISM tree before its remote counterparts can be enrolled`);
1020
+ const instance = DelayedFlowRouterHookIsm__factory.connect(ismAddress, provider);
1021
+ // maxDelay() is what identifies the contract: NetFlowRateLimitedHookIsm
1022
+ // exposes warpRouter() too, so probing that alone would return a NetFlow
1023
+ // instance as a DELAYED_FLOW_ROUTER enrollment target and pair the wrong
1024
+ // contract. Only DelayedFlowRouterHookIsm declares maxDelay (a uint48
1025
+ // immutable), the same discriminator EvmIsmReader and EvmHookReader use,
1026
+ // and it returns a value, so a contract missing the selector reverts
1027
+ // instead of decoding empty returndata as a match.
1028
+ //
1029
+ // The router's current hook is only a DelayedFlowRouterHookIsm once the
1030
+ // hybrid has been wired; on an older route it can be another hook or the
1031
+ // zero address, and these getters would then revert inside the provider
1032
+ // with a raw call exception instead of reaching the asserts below.
1033
+ let onChainMaxDelay;
1034
+ try {
1035
+ onChainMaxDelay = await instance.maxDelay();
1036
+ }
1037
+ catch (error) {
1038
+ throwIfNotMissingSelector(error);
1039
+ }
1040
+ assert(onChainMaxDelay !== undefined, `Contract ${ismAddress} on ${chain} does not expose maxDelay(), so it is not a DelayedFlowRouterHookIsm — resolve the instance from the ISM tree the router actually installs`);
1041
+ let onChainWarpRouter;
1042
+ try {
1043
+ onChainWarpRouter = await instance.warpRouter();
1044
+ }
1045
+ catch (error) {
1046
+ throwIfNotMissingSelector(error);
1047
+ }
1048
+ assert(onChainWarpRouter !== undefined, `Contract ${ismAddress} on ${chain} does not expose warpRouter(), so it is not the token's DelayedFlowRouterHookIsm — resolve the instance from the ISM tree the router actually installs`);
1049
+ assert(eqAddress(onChainWarpRouter, tokenAddress), `Contract ${ismAddress} on ${chain} is not the token's DelayedFlowRouterHookIsm (warpRouter mismatch)`);
1050
+ targets[chain] = { ismAddress, userNode: delayedNodes[0] };
1051
+ }
1052
+ return targets;
1053
+ }
1054
+ /**
1055
+ * Builds the enrollment transactions for one chain's
1056
+ * DelayedFlowRouterHookIsm: enrolls every other chain's instance as a remote
1057
+ * counterpart, plus configured peers outside the route, and
1058
+ * optionally transfers ownership to the configured owner LAST (the enrollment
1059
+ * calls are owner-gated). Converges to zero transactions once the on-chain
1060
+ * state already matches.
1061
+ *
1062
+ * Shared by the deploy flow (deployer-owned instances) and `warp apply`
1063
+ * (instances that may be owned by a Safe/ICA — the returned transactions carry
1064
+ * no signing assumptions and ride whichever submitter the caller uses).
1065
+ */
1066
+ export async function buildDelayedFlowEnrollmentTxs({ chain, multiProvider, registryAddresses, warpRouter, target, allTargets, reconcileOwnership = true, }) {
1067
+ const { ismAddress, userNode } = target;
1068
+ const derivedRemoteIsms = Object.fromEntries(Object.entries(allTargets)
1069
+ .filter(([otherChain]) => otherChain !== chain)
1070
+ .map(([otherChain, otherTarget]) => [
1071
+ otherChain,
1072
+ addressToBytes32(otherTarget.ismAddress).toLowerCase(),
1073
+ ]));
1074
+ // Same resolution expandWarpDeployConfig applies to the expected config, so
1075
+ // `warp check` compares exactly the enrollment installed here.
1076
+ const remoteIsms = resolveDelayedFlowRemoteIsms(userNode.remoteIsms, derivedRemoteIsms, `DelayedFlowRouterHookIsm ${ismAddress} on ${chain}`, multiProvider);
1077
+ const delayedFlowIsmModule = new EvmIsmModule(multiProvider, {
1078
+ chain,
1079
+ config: userNode,
1080
+ addresses: {
1081
+ ...extractIsmAndHookFactoryAddresses(registryAddresses[chain]),
1082
+ mailbox: registryAddresses[chain].mailbox,
1083
+ deployedIsm: ismAddress,
1084
+ },
1085
+ });
1086
+ const current = await delayedFlowIsmModule.read();
1087
+ assert(typeof current === 'object' && current.type === IsmType.DELAYED_FLOW_ROUTER, `Expected ${ismAddress} on ${chain} to be a ${IsmType.DELAYED_FLOW_ROUTER}`);
1088
+ // Single-instance reconciliation: the tree's composition was validated when
1089
+ // it was deployed. Warp apply preserves ownership here because its existing
1090
+ // ownership phase handles the shared instance alongside the router.
1091
+ return delayedFlowIsmModule.updateDeployedInstance({
1092
+ type: IsmType.DELAYED_FLOW_ROUTER,
1093
+ warpRouter,
1094
+ thresholdBps: userNode.thresholdBps,
1095
+ maxDelay: userNode.maxDelay,
1096
+ duration: userNode.duration,
1097
+ owner: reconcileOwnership ? userNode.owner : current.owner,
1098
+ remoteIsms,
1099
+ });
1100
+ }
478
1101
  export async function enrollCrossChainRouters({ multiProvider, altVmSigners, registryAddresses, warpDeployConfig, }, deployedContracts) {
479
1102
  rootLogger.info(`Start enrolling cross chain routers`);
480
1103
  assertWarpConfigTimelocksSupportedByProtocols({
481
1104
  multiProvider,
482
1105
  warpDeployConfig,
483
1106
  });
1107
+ // Resolve every delayed-flow counterpart before building per-chain batches.
1108
+ // Initial deployment already enrolled these pairs; this final pass repairs
1109
+ // any drift while the deployer still owns the instances.
1110
+ const hasDelayedFlow = Object.values(warpDeployConfig).some((config) => typeof config.interchainSecurityModule === 'object' &&
1111
+ config.interchainSecurityModule !== null &&
1112
+ collectHybridIsmNodes(config.interchainSecurityModule).some((node) => node.type === IsmType.DELAYED_FLOW_ROUTER));
1113
+ const delayedFlowTargets = hasDelayedFlow
1114
+ ? await deriveDelayedFlowEnrollmentTargets(multiProvider, warpDeployConfig, deployedContracts, await readInstalledHybridIsmAddresses(multiProvider, deployedContracts))
1115
+ : {};
484
1116
  const resolvedConfigMap = objMap(warpDeployConfig, (_, config) => ({
485
1117
  gas: gasOverhead(config.type),
486
1118
  ...config,
@@ -545,9 +1177,24 @@ export async function enrollCrossChainRouters({ multiProvider, altVmSigners, reg
545
1177
  },
546
1178
  });
547
1179
  const actualConfig = await evmWarpModule.read();
1180
+ const targetOwner = resolvedConfigMap[currentChain].owner;
548
1181
  const expectedConfig = {
549
1182
  ...actualConfig,
550
- owner: resolvedConfigMap[currentChain].owner,
1183
+ owner: targetOwner,
1184
+ // Deployment leaves the router and its shared hybrid instance with
1185
+ // the deployer until this final pass. Describe their common target
1186
+ // owner on both config surfaces so EvmWarpModule can transfer them
1187
+ // together after enrollment.
1188
+ interchainSecurityModule: typeof actualConfig.interchainSecurityModule === 'object' &&
1189
+ actualConfig.interchainSecurityModule
1190
+ ? mapHybridIsmNodes(actualConfig.interchainSecurityModule, (node) => ({ ...node, owner: targetOwner }))
1191
+ : actualConfig.interchainSecurityModule,
1192
+ hook: typeof actualConfig.hook === 'object' && actualConfig.hook
1193
+ ? mapHybridHookNodes(actualConfig.hook, (node) => ({
1194
+ ...node,
1195
+ owner: targetOwner,
1196
+ }))
1197
+ : actualConfig.hook,
551
1198
  // Deploy set the ProxyAdmin owner to the intermediate deployer owner
552
1199
  // so post-deploy enrollment could be self-signed. actualConfig reads
553
1200
  // that live (deployer) owner, so carry the configured owner through
@@ -572,9 +1219,25 @@ export async function enrollCrossChainRouters({ multiProvider, altVmSigners, reg
572
1219
  tokenFee: resolvedConfigMap[currentChain].tokenFee,
573
1220
  }),
574
1221
  };
575
- transactions = await evmWarpModule.update(expectedConfig, {
1222
+ const { txs, feeTxs, ownershipTxs } = await evmWarpModule.updateSplit(expectedConfig, {
576
1223
  routingDestinations: Object.keys(remoteRouters).map((domain) => parseInt(domain, 10)),
577
1224
  });
1225
+ transactions = [...txs, ...feeTxs];
1226
+ const delayedFlowTarget = delayedFlowTargets[currentChain];
1227
+ if (delayedFlowTarget) {
1228
+ transactions.push(...(await buildDelayedFlowEnrollmentTxs({
1229
+ chain: currentChain,
1230
+ multiProvider,
1231
+ registryAddresses,
1232
+ warpRouter: deployedContracts[currentChain],
1233
+ target: delayedFlowTarget,
1234
+ allTargets: delayedFlowTargets,
1235
+ reconcileOwnership: false,
1236
+ })));
1237
+ }
1238
+ // The router, ProxyAdmin, and shared hybrid move together only after
1239
+ // router and delayed-flow enrollment have converged.
1240
+ transactions.push(...ownershipTxs);
578
1241
  break;
579
1242
  }
580
1243
  default: {