@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
@@ -13,16 +13,20 @@ import { EvmTokenFeeModule } from '../fee/EvmTokenFeeModule.js';
13
13
  import { mergeCrossCollateralRouters } from '../fee/crossCollateralUtils.js';
14
14
  import { TokenFeeType } from '../fee/types.js';
15
15
  import { getEvmHookUpdateTransactions } from '../hook/updates.js';
16
- import { stripPredicateSubHook } from '../hook/utils.js';
17
- import { OnchainHookType } from '../hook/types.js';
16
+ import { collectHybridHookNodes, resolveHybridHookNodesToAddress, stripPredicateSubHook, } from '../hook/utils.js';
17
+ import { HookType, OnchainHookType, } from '../hook/types.js';
18
18
  import { EvmIsmModule } from '../ism/EvmIsmModule.js';
19
+ import { EvmIsmReader } from '../ism/EvmIsmReader.js';
20
+ import { HyperlaneIsmFactory } from '../ism/HyperlaneIsmFactory.js';
21
+ import { moduleMatchesConfig } from '../ism/utils.js';
19
22
  import { PredicateWrapperDeployer } from '../predicate/PredicateDeployer.js';
20
23
  import { resolveRouterMapConfig } from '../router/types.js';
21
24
  import { CANCELLER_ROLE, EXECUTOR_ROLE, PROPOSER_ROLE, TIMELOCK_ADMIN_ROLE, } from '../timelock/evm/constants.js';
22
25
  import { isDeterministicTimelockReadError } from '../timelock/evm/errors.js';
26
+ import { planChainHybrid, } from '../deploy/warpHybridPlan.js';
23
27
  import { scalesEqual } from '../utils/decimals.js';
24
28
  import { IsmType } from '../ism/types.js';
25
- import { extractIsmAndHookFactoryAddresses, ismTreeContainsRateLimited, setRateLimitedIsmRecipient, } from '../utils/ism.js';
29
+ import { DelayedFlowRemoteIsmsSourceType, collectHybridIsmNodes, completeHybridIsmNodes, extractIsmAndHookFactoryAddresses, ismTreeContainsHybridHookIsm, ismTreeContainsRateLimited, normalizeConfig, resolveHybridIsmNodesToAddress, setRateLimitedIsmRecipient, } from '../utils/ism.js';
26
30
  import { CCTP_PPM_STORAGE_VERSION, EvmWarpRouteReader, } from './EvmWarpRouteReader.js';
27
31
  import { EvmXERC20Module } from './EvmXERC20Module.js';
28
32
  import { TokenType } from './config.js';
@@ -198,7 +202,7 @@ export class EvmWarpModule extends HyperlaneModule {
198
202
  return deployedTimelockByConfig;
199
203
  }
200
204
  /**
201
- * Updates the Warp Route contract with the provided configuration.
205
+ * Plans a Warp Route update as per-chain transaction groups.
202
206
  *
203
207
  * IMPORTANT — irreversible side effects when expectedConfig includes `predicateWrapper`:
204
208
  * The PredicateRouterWrapper contract is deployed on-chain during planning (before this
@@ -213,11 +217,21 @@ export class EvmWarpModule extends HyperlaneModule {
213
217
  * transaction plan can still leave an untracked timelock deployment.
214
218
  *
215
219
  * @param expectedConfig - The configuration for the token router to be updated.
216
- * @returns `{txs, feeTxs, ownershipTxs}` — main txs (includes router-owner `setFeeRecipient`), fee-contract-owner txs (safe to route to a dedicated feeSubmitter), and ownership/proxyAdmin txs that must execute last.
220
+ * Used by warp apply to assemble one ordered batch for this chain.
217
221
  */
218
- async updateSplit(expectedConfig, tokenReaderParams) {
222
+ async updatePhases(expectedConfig, tokenReaderParams) {
219
223
  HypTokenRouterConfigSchema.parse(expectedConfig);
220
224
  const actualConfig = await this.read();
225
+ const hybridPlan = this.planHybridUpdate(actualConfig, expectedConfig);
226
+ const actualHasDelayedFlowRouter = (typeof actualConfig.hook === 'object' &&
227
+ collectHybridHookNodes(actualConfig.hook).some(({ type }) => type === HookType.DELAYED_FLOW_ROUTER)) ||
228
+ (typeof actualConfig.interchainSecurityModule === 'object' &&
229
+ collectHybridIsmNodes(actualConfig.interchainSecurityModule).some(({ type }) => type === IsmType.DELAYED_FLOW_ROUTER));
230
+ const targetHasDelayedFlowRouter = (typeof expectedConfig.hook === 'object' &&
231
+ collectHybridHookNodes(expectedConfig.hook).some(({ type }) => type === HookType.DELAYED_FLOW_ROUTER)) ||
232
+ (typeof expectedConfig.interchainSecurityModule === 'object' &&
233
+ collectHybridIsmNodes(expectedConfig.interchainSecurityModule).some(({ type }) => type === IsmType.DELAYED_FLOW_ROUTER));
234
+ const removesDelayedFlowRouter = actualHasDelayedFlowRouter && !targetHasDelayedFlowRouter;
221
235
  expectedConfig = await this.configWithTimelockProxyAdminOwner(actualConfig, expectedConfig);
222
236
  const transactions = [];
223
237
  let xerc20Txs = [];
@@ -243,9 +257,35 @@ export class EvmWarpModule extends HyperlaneModule {
243
257
  * because GasRouter requires routers to be enrolled before setting destination gas
244
258
  * 3. createHookAndPredicateUpdateTxs() handles hook + predicate wrapper together so the
245
259
  * pending new hook address is threaded through without leaking into other method signatures
260
+ * 4. The hook transactions are pushed BEFORE the ISM transactions, whatever the order they
261
+ * are built in — see the comment at the push
246
262
  */
247
263
  const upgradeTxs = await this.upgradeWarpRouteImplementationTx(actualConfig, expectedConfig);
248
- transactions.push(...upgradeTxs, ...(await this.createIsmUpdateTxs(actualConfig, expectedConfig)), ...(await this.createHookAndPredicateUpdateTxs(actualConfig, expectedConfig)), ...this.createFeeHookUpdateTxs(actualConfig, expectedConfig), ...this.createUnenrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig), ...this.createEnrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig),
264
+ // Pure hybrid validation already ran immediately after the read. Resolve
265
+ // (and, when needed, deploy) the shared instance for both config trees.
266
+ const hybrid = await this.resolveHybridInstance(actualConfig, expectedConfig, hybridPlan);
267
+ // With the shared address known, both trees reference it as an ordinary
268
+ // address child and the existing ISM/hook modules deploy whatever parents
269
+ // remain. Nothing downstream needs to know a hybrid is involved.
270
+ const resolvedConfig = hybrid
271
+ ? {
272
+ ...expectedConfig,
273
+ interchainSecurityModule: resolveHybridIsmNodesToAddress(hybrid.ismTree, hybrid.address),
274
+ hook: resolveHybridHookNodesToAddress(hybrid.hookTree, hybrid.address),
275
+ }
276
+ : expectedConfig;
277
+ const ismTxs = await this.createIsmUpdateTxs(actualConfig, resolvedConfig, hybrid?.address);
278
+ // Built after the ISM step, which may deploy the tree, but ORDERED BEFORE
279
+ // it: a batch executes sequentially with no rollback, so a failure between
280
+ // the two installs is a state the route can be left in. Hook first leaves
281
+ // the previous ISM verifying inbound messages. ISM first can leave a hybrid
282
+ // hook/ISM gating delivery while nothing drives its postDispatch — no
283
+ // preverification is ever sent for the messages dispatched in that window,
284
+ // and none can be sent afterwards, so they are permanently undeliverable.
285
+ // Returned in their own buckets so callers can choose the safe order within
286
+ // this chain's batch, including reversing it when removing delayed flow.
287
+ const hookTxs = await this.createHookAndPredicateUpdateTxs(actualConfig, resolvedConfig, hybrid?.address);
288
+ transactions.push(...this.createFeeHookUpdateTxs(actualConfig, expectedConfig), ...this.createUnenrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig), ...this.createEnrollRemoteRoutersUpdateTxs(actualConfig, expectedConfig),
249
289
  // MC unenroll before enroll for consistency with remote routers.
250
290
  // MC enrollment must come before gas setting so that MC-only domains
251
291
  ...this.createUnenrollCrossCollateralRoutersTxs(actualConfig, expectedConfig), ...this.createEnrollCrossCollateralRoutersTxs(actualConfig, expectedConfig), ...this.createSetDestinationGasUpdateTxs(actualConfig, expectedConfig), ...this.createAddRebalancersUpdateTxs(actualConfig, expectedConfig), ...this.createRemoveRebalancersUpdateTxs(actualConfig, expectedConfig), ...(await this.createAddAllowedBridgesUpdateTxs(actualConfig, expectedConfig)), ...this.createRemoveBridgesTxs(actualConfig, expectedConfig), ...this.createAddRebalanceTargetsUpdateTxs(actualConfig, expectedConfig), ...this.createRemoveRebalanceTargetsTxs(actualConfig, expectedConfig), ...this.createSetRecipientsUpdateTxs(actualConfig, expectedConfig), ...this.createRemoveRecipientsTxs(actualConfig, expectedConfig), ...(await this.createRevokeStaleBridgeAllowancesTxs(actualConfig, expectedConfig, upgradeTxs.length > 0)), ...this.createAddRemoteOutputAssetsTxs(actualConfig, expectedConfig), ...this.createRemoveRemoteOutputAssetsTxs(actualConfig, expectedConfig), ...this.createUpdateEverclearFeeParamsTxs(actualConfig, expectedConfig), ...this.createRemoveEverclearFeeParamsTxs(actualConfig, expectedConfig), ...this.createSetMaxFeePpmTxs(actualConfig, expectedConfig), ...xerc20Txs,
@@ -256,21 +296,173 @@ export class EvmWarpModule extends HyperlaneModule {
256
296
  // Ownership/proxyAdmin must always execute last; returned separately so callers
257
297
  // can place feeTxs between main txs and ownership (see update() below).
258
298
  const ownershipTxs = [
299
+ ...(hybrid?.ownershipTxs ?? []),
259
300
  ...this.createOwnershipUpdateTxs(actualConfig, expectedConfig),
260
301
  ...proxyAdminUpdateTxs(this.chainId, this.args.addresses.deployedTokenRoute, actualConfig, expectedConfig),
261
302
  ];
262
- return { txs: transactions, feeTxs, ownershipTxs };
303
+ return {
304
+ upgradeTxs,
305
+ instanceTxs: hybrid?.instanceTxs ?? [],
306
+ hookTxs,
307
+ ismTxs,
308
+ txs: transactions,
309
+ feeTxs,
310
+ ownershipTxs,
311
+ hybridIsm: hybrid?.address,
312
+ removesDelayedFlowRouter,
313
+ };
263
314
  }
264
315
  /**
265
- * Backwards-compatible wrapper around `updateSplit`. Returns a flat, ordered
266
- * transaction array suitable for a single submitter.
316
+ * Main-compatible split: router-owner work, fee-owner work, then ownership.
267
317
  */
318
+ async updateSplit(expectedConfig, tokenReaderParams) {
319
+ const { upgradeTxs, instanceTxs, hookTxs, ismTxs, txs, feeTxs, ownershipTxs, removesDelayedFlowRouter, } = await this.updatePhases(expectedConfig, tokenReaderParams);
320
+ return {
321
+ txs: removesDelayedFlowRouter
322
+ ? [...upgradeTxs, ...instanceTxs, ...ismTxs, ...hookTxs, ...txs]
323
+ : [...upgradeTxs, ...instanceTxs, ...hookTxs, ...ismTxs, ...txs],
324
+ feeTxs,
325
+ ownershipTxs,
326
+ };
327
+ }
328
+ /** Returns a flat, ordered transaction array suitable for one submitter. */
268
329
  async update(expectedConfig, tokenReaderParams) {
269
330
  const { txs, feeTxs, ownershipTxs } = await this.updateSplit(expectedConfig, tokenReaderParams);
270
331
  // feeTxs (fee-contract-owner calls) must come before ownershipTxs so they
271
332
  // execute before the router owner changes.
272
333
  return [...txs, ...feeTxs, ...ownershipTxs];
273
334
  }
335
+ /**
336
+ * Validates hybrid composition and fee-hook transitions before update
337
+ * planning reaches any operation that may deploy a contract.
338
+ */
339
+ planHybridUpdate(actualConfig, expectedConfig) {
340
+ // An omitted feeHook is intentionally left unchanged by warp apply. Plan
341
+ // against the effective value so an already-installed ERC20 fee hook
342
+ // cannot bypass hybrid hook composition validation.
343
+ const effectiveFeeHook = expectedConfig.feeHook ?? actualConfig.feeHook;
344
+ const plan = planChainHybrid({
345
+ multiProvider: this.multiProvider,
346
+ chain: this.chainName,
347
+ config: { ...expectedConfig, feeHook: effectiveFeeHook },
348
+ });
349
+ if (!plan)
350
+ return undefined;
351
+ if (plan.node.type === IsmType.DELAYED_FLOW_ROUTER &&
352
+ actualConfig.feeHook &&
353
+ !isZeroishAddress(actualConfig.feeHook) &&
354
+ expectedConfig.feeHook &&
355
+ isZeroishAddress(expectedConfig.feeHook)) {
356
+ const actualHook = actualConfig.hook;
357
+ const expectedHook = expectedConfig.hook;
358
+ const installedHybrid = typeof actualHook === 'object'
359
+ ? collectHybridHookNodes(actualHook)[0]
360
+ : undefined;
361
+ const delayedFlowHookStaysInstalled = installedHybrid?.type === HookType.DELAYED_FLOW_ROUTER &&
362
+ installedHybrid.duration === plan.node.duration &&
363
+ installedHybrid.thresholdBps === plan.node.thresholdBps &&
364
+ installedHybrid.maxDelay === plan.node.maxDelay &&
365
+ (plan.node.warpRouter === undefined ||
366
+ (!!installedHybrid.warpRouter &&
367
+ eqAddress(installedHybrid.warpRouter, plan.node.warpRouter))) &&
368
+ typeof expectedHook === 'object' &&
369
+ deepEquals(normalizeConfig(resolveHybridHookNodesToAddress(actualHook, constants.AddressZero)), normalizeConfig(resolveHybridHookNodesToAddress(expectedHook, constants.AddressZero)));
370
+ assert(delayedFlowHookStaysInstalled, `Cannot introduce, replace, or recompose ${IsmType.DELAYED_FLOW_ROUTER} on ${this.chainName} while clearing existing feeHook ${actualConfig.feeHook} in the same warp apply. Hook installation runs before router updates, leaving the delayed-flow hook active while ERC20 fee metadata still prevents its native control dispatch. Apply feeHook address(0) without changing the hook first, execute that update, then apply the ${IsmType.DELAYED_FLOW_ROUTER} config.`);
371
+ }
372
+ return plan;
373
+ }
374
+ /**
375
+ * Reuses or deploys the one hybrid leaf declared on both config surfaces.
376
+ * Parent trees only ever receive the returned address, preventing duplicate
377
+ * bucket state. Instance mutations are returned separately so counterpart
378
+ * enrollment can precede either router-surface change in this chain's batch.
379
+ */
380
+ async resolveHybridInstance(actualConfig, expectedConfig, plan) {
381
+ if (!plan)
382
+ return undefined;
383
+ const completed = completeHybridIsmNodes(plan.node, this.args.addresses.deployedTokenRoute, { type: DelayedFlowRemoteIsmsSourceType.Deferred }, actualConfig.owner, this.multiProvider);
384
+ assert(typeof completed === 'object' &&
385
+ (completed.type === IsmType.NET_FLOW_RATE_LIMITED ||
386
+ completed.type === IsmType.DELAYED_FLOW_ROUTER), `Failed to complete hybrid hook/ISM config on ${this.chainName}`);
387
+ // Every owner-gated mutation is signed by the router's current owner.
388
+ // Ownership moves to the target owner only in the final ownership phase.
389
+ const desired = {
390
+ ...completed,
391
+ owner: actualConfig.owner,
392
+ };
393
+ const actualHook = actualConfig.hook;
394
+ const hookNodes = typeof actualHook === 'object' ? collectHybridHookNodes(actualHook) : [];
395
+ const actualIsm = actualConfig.interchainSecurityModule;
396
+ const ismNodes = typeof actualIsm === 'object' ? collectHybridIsmNodes(actualIsm) : [];
397
+ assert(hookNodes.length <= 1 && ismNodes.length <= 1, `Expected at most one installed hybrid hook/ISM on each surface of ${this.chainName}, found ${hookNodes.length} under hook and ${ismNodes.length} under interchainSecurityModule`);
398
+ // Prefer the hook: submission can be interrupted after its update but before
399
+ // the ISM update, leaving the new leaf installed while the ISM still has the
400
+ // old tree. A later apply must reuse that new leaf.
401
+ const candidate = hookNodes[0] ?? ismNodes[0];
402
+ const currentAddress = candidate &&
403
+ 'address' in candidate &&
404
+ typeof candidate.address === 'string'
405
+ ? candidate.address
406
+ : undefined;
407
+ if (currentAddress) {
408
+ const current = await new EvmIsmReader(this.multiProvider, this.chainName).deriveIsmConfig(currentAddress);
409
+ assert(typeof current === 'object' &&
410
+ (current.type === IsmType.NET_FLOW_RATE_LIMITED ||
411
+ current.type === IsmType.DELAYED_FLOW_ROUTER), `Expected ${currentAddress} on ${this.chainName} to be a hybrid hook/ISM`);
412
+ const ownedByRouter = current.owner && eqAddress(current.owner, actualConfig.owner);
413
+ const alreadyTransferred = !ownedByRouter &&
414
+ current.owner &&
415
+ expectedConfig.owner &&
416
+ eqAddress(current.owner, expectedConfig.owner);
417
+ assert(ownedByRouter || alreadyTransferred, `Hybrid hook/ISM ${currentAddress} on ${this.chainName} is owned by ${current.owner}, but its warp router is owned by ${actualConfig.owner}. Warp apply uses the router's submitter for every non-fee mutation, so align the owners before applying this config.`);
418
+ if (alreadyTransferred) {
419
+ const factory = HyperlaneIsmFactory.fromAddressesMap({ [this.chainName]: this.args.addresses }, this.multiProvider, this.ccipContractCache, this.contractVerifier);
420
+ const matchesFinalConfig = await moduleMatchesConfig(this.chainName, currentAddress, { ...completed, owner: expectedConfig.owner }, this.multiProvider, factory.getContracts(this.chainName), actualConfig.mailbox);
421
+ assert(matchesFinalConfig, `Hybrid hook/ISM ${currentAddress} on ${this.chainName} was already transferred to the target owner ${expectedConfig.owner}, but its config does not match the target. Complete or revert the interrupted ownership transfer before changing its config.`);
422
+ return {
423
+ address: currentAddress,
424
+ ismTree: plan.ismTree,
425
+ hookTree: plan.hookTree,
426
+ instanceTxs: [],
427
+ ownershipTxs: [],
428
+ };
429
+ }
430
+ const module = new EvmIsmModule(this.multiProvider, {
431
+ chain: this.chainName,
432
+ config: current,
433
+ addresses: {
434
+ ...this.args.addresses,
435
+ mailbox: actualConfig.mailbox,
436
+ deployedIsm: currentAddress,
437
+ },
438
+ }, this.ccipContractCache, this.contractVerifier);
439
+ const txs = await module.updateDeployedInstance(desired);
440
+ const { deployedIsm } = module.serialize();
441
+ assert(deployedIsm, `Failed to resolve hybrid hook/ISM on ${this.chainName}`);
442
+ const ownershipTxs = transferOwnershipTransactions(this.chainId, deployedIsm, { owner: actualConfig.owner }, { owner: expectedConfig.owner }, `hybrid hook/ISM on ${this.chainName}`);
443
+ return {
444
+ address: deployedIsm,
445
+ ismTree: plan.ismTree,
446
+ hookTree: plan.hookTree,
447
+ instanceTxs: txs,
448
+ ownershipTxs,
449
+ };
450
+ }
451
+ const factory = HyperlaneIsmFactory.fromAddressesMap({ [this.chainName]: this.args.addresses }, this.multiProvider, this.ccipContractCache, this.contractVerifier);
452
+ const deployed = await factory.deployInternal({
453
+ destination: this.chainName,
454
+ config: desired,
455
+ mailbox: actualConfig.mailbox,
456
+ });
457
+ const ownershipTxs = transferOwnershipTransactions(this.chainId, deployed.address, { owner: actualConfig.owner }, { owner: expectedConfig.owner }, `hybrid hook/ISM on ${this.chainName}`);
458
+ return {
459
+ address: deployed.address,
460
+ ismTree: plan.ismTree,
461
+ hookTree: plan.hookTree,
462
+ instanceTxs: [],
463
+ ownershipTxs,
464
+ };
465
+ }
274
466
  /**
275
467
  * Create a transaction to update the remote routers for the Warp Route contract.
276
468
  *
@@ -907,14 +1099,14 @@ export class EvmWarpModule extends HyperlaneModule {
907
1099
  * @param expectedConfig - The expected token router configuration, including the ISM configuration.
908
1100
  * @returns Ethereum transaction that need to be executed to update the ISM configuration.
909
1101
  */
910
- async createIsmUpdateTxs(actualConfig, expectedConfig) {
1102
+ async createIsmUpdateTxs(actualConfig, expectedConfig, opaqueHybridAddress) {
911
1103
  const updateTransactions = [];
912
1104
  if (!expectedConfig.interchainSecurityModule) {
913
1105
  return [];
914
1106
  }
915
1107
  const actualDeployedIsm = derivedIsmAddress(actualConfig);
916
1108
  // Try to update (may also deploy) Ism with the expected config
917
- const { deployedIsm: expectedDeployedIsm, updateTransactions: ismUpdateTransactions, } = await this.deployOrUpdateIsm(actualConfig, expectedConfig);
1109
+ const { deployedIsm: expectedDeployedIsm, updateTransactions: ismUpdateTransactions, } = await this.deployOrUpdateIsm(actualConfig, expectedConfig, opaqueHybridAddress);
918
1110
  // If an ISM is updated in-place, push the update txs
919
1111
  updateTransactions.push(...ismUpdateTransactions);
920
1112
  // If a new ISM is deployed, push the setInterchainSecurityModule tx
@@ -936,7 +1128,7 @@ export class EvmWarpModule extends HyperlaneModule {
936
1128
  * Deploys hook updates and predicate wrapper together so the post-update hook address
937
1129
  * is available to deployAndConfigure without a stale on-chain read.
938
1130
  */
939
- async createHookAndPredicateUpdateTxs(actualConfig, expectedConfig) {
1131
+ async createHookAndPredicateUpdateTxs(actualConfig, expectedConfig, opaqueHybridAddress) {
940
1132
  let hookTransactions = [];
941
1133
  let newHookAddress;
942
1134
  // Explicit type annotation narrows away the undefined that TypeScript infers
@@ -957,6 +1149,10 @@ export class EvmWarpModule extends HyperlaneModule {
957
1149
  ? expectedConfig.predicateWrapper
958
1150
  : undefined;
959
1151
  const needsPredicateRemoval = actualPredicateWrapper != null && !expectedPredicateWrapper;
1152
+ const needsDelayedFlowRemoval = typeof expectedConfig.hook === 'string' &&
1153
+ isZeroishAddress(expectedConfig.hook) &&
1154
+ typeof actualHook === 'object' &&
1155
+ collectHybridHookNodes(actualHook).some(({ type }) => type === HookType.DELAYED_FLOW_ROUTER);
960
1156
  // Treat a zero-address hook the same as "no explicit hook": expandWarpDeployConfig
961
1157
  // sets hook: zeroAddress as a default when the user config omits the hook field.
962
1158
  // EvmHookModule.update(zeroAddress) returns [] early without updating deployedHook,
@@ -995,20 +1191,24 @@ export class EvmWarpModule extends HyperlaneModule {
995
1191
  multiProvider: this.multiProvider,
996
1192
  proxyAdminAddress,
997
1193
  rateLimitedSender: this.args.addresses.deployedTokenRoute,
1194
+ opaqueHybridAddresses: opaqueHybridAddress
1195
+ ? [opaqueHybridAddress]
1196
+ : undefined,
998
1197
  });
999
1198
  hookTransactions = result.transactions;
1000
1199
  newHookAddress = result.newHookAddress;
1001
1200
  }
1002
- // Predicate removal when no new hook was deployed: clear the custom hook entirely.
1003
- // This fires whether or not expectedConfig.hook was provided it handles both the
1004
- // "no hook field" case and the round-trip hazard where the operator removed
1005
- // predicateWrapper but left an unchanged hook field (still containing the aggregation).
1006
- if (needsPredicateRemoval && !newHookAddress) {
1201
+ // Predicate and delayed-flow removals clear the custom hook when no
1202
+ // replacement was deployed. Delayed flow requires an explicit zero target;
1203
+ // omission retains the existing hook like every ordinary update.
1204
+ if ((needsPredicateRemoval || needsDelayedFlowRemoval) && !newHookAddress) {
1007
1205
  const currentAddress = typeof actualHook === 'string' ? actualHook : actualHook.address;
1008
1206
  if (!isZeroishAddress(currentAddress)) {
1009
- this.logger.debug({ chain: this.chainName }, 'Removing predicate wrapper: generating setHook(zero) to clear custom hook');
1207
+ this.logger.debug({ chain: this.chainName }, 'Removing custom hook: generating setHook(zero)');
1010
1208
  hookTransactions.push({
1011
- annotation: 'Remove predicate wrapper: clear custom hook (router will use mailbox default)',
1209
+ annotation: needsDelayedFlowRemoval
1210
+ ? 'Remove delayed-flow hook (router will use mailbox default)'
1211
+ : 'Remove predicate wrapper: clear custom hook (router will use mailbox default)',
1012
1212
  chainId: this.chainId,
1013
1213
  to: this.args.addresses.deployedTokenRoute,
1014
1214
  data: MailboxClient__factory.createInterface().encodeFunctionData('setHook', [constants.AddressZero]),
@@ -1329,7 +1529,7 @@ export class EvmWarpModule extends HyperlaneModule {
1329
1529
  *
1330
1530
  * @returns Object with deployedIsm address, and update Transactions
1331
1531
  */
1332
- async deployOrUpdateIsm(actualConfig, expectedConfig) {
1532
+ async deployOrUpdateIsm(actualConfig, expectedConfig, opaqueHybridAddress) {
1333
1533
  assert(expectedConfig.interchainSecurityModule, 'Ism derived incorrectly');
1334
1534
  if (typeof expectedConfig.interchainSecurityModule === 'string' &&
1335
1535
  isZeroishAddress(expectedConfig.interchainSecurityModule)) {
@@ -1348,15 +1548,27 @@ export class EvmWarpModule extends HyperlaneModule {
1348
1548
  // Primary default: the warp-route owner (matches deploy path in token/deploy.ts).
1349
1549
  // Fallback: the current on-chain ISM owner (same-type, same-owner in-place update).
1350
1550
  let expectedIsm = expectedConfig.interchainSecurityModule;
1351
- if (typeof expectedIsm === 'object' &&
1352
- ismTreeContainsRateLimited(expectedIsm)) {
1551
+ if (typeof expectedIsm === 'object') {
1353
1552
  const actualIsm = actualConfig.interchainSecurityModule;
1354
1553
  const onChainOwner = typeof actualIsm === 'object' && 'owner' in actualIsm
1355
1554
  ? actualIsm.owner
1356
1555
  : undefined;
1357
1556
  const defaultOwner = expectedConfig.owner ??
1358
1557
  (typeof onChainOwner === 'string' ? onChainOwner : undefined);
1359
- expectedIsm = setRateLimitedIsmRecipient(expectedIsm, this.args.addresses.deployedTokenRoute, defaultOwner);
1558
+ if (ismTreeContainsRateLimited(expectedIsm)) {
1559
+ expectedIsm = setRateLimitedIsmRecipient(expectedIsm, this.args.addresses.deployedTokenRoute, defaultOwner);
1560
+ }
1561
+ // Hybrid hook/ISMs need the paired warp router, which in warp-route
1562
+ // context is always this token — configs routinely omit it (it is only
1563
+ // required for standalone ISM deploys). Injected independently of the
1564
+ // owner: an unresolvable owner would otherwise leave `warpRouter`
1565
+ // missing and the update would plan against an incomplete config.
1566
+ // `remoteIsms` is deferred: cross-chain enrollment needs every chain's
1567
+ // instance address, so it is reconciled by a separate pass that must
1568
+ // stay its only writer.
1569
+ if (ismTreeContainsHybridHookIsm(expectedIsm)) {
1570
+ expectedIsm = completeHybridIsmNodes(expectedIsm, this.args.addresses.deployedTokenRoute, { type: DelayedFlowRemoteIsmsSourceType.Deferred }, defaultOwner, this.multiProvider);
1571
+ }
1360
1572
  }
1361
1573
  const ismModule = new EvmIsmModule(this.multiProvider, {
1362
1574
  chain: this.args.chain,
@@ -1368,7 +1580,7 @@ export class EvmWarpModule extends HyperlaneModule {
1368
1580
  },
1369
1581
  }, this.ccipContractCache, this.contractVerifier);
1370
1582
  this.logger.info(`Comparing target ISM config with ${this.args.chain} chain`);
1371
- const updateTransactions = await ismModule.update(expectedIsm);
1583
+ const updateTransactions = await ismModule.update(expectedIsm, opaqueHybridAddress ? [opaqueHybridAddress] : []);
1372
1584
  const { deployedIsm } = ismModule.serialize();
1373
1585
  return { deployedIsm, updateTransactions };
1374
1586
  }