@hyperlane-xyz/rebalancer 2.0.0 → 3.1.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 (213) hide show
  1. package/dist/bridges/LiFiBridge.d.ts +67 -0
  2. package/dist/bridges/LiFiBridge.d.ts.map +1 -0
  3. package/dist/bridges/LiFiBridge.js +386 -0
  4. package/dist/bridges/LiFiBridge.js.map +1 -0
  5. package/dist/config/RebalancerConfig.d.ts +8 -2
  6. package/dist/config/RebalancerConfig.d.ts.map +1 -1
  7. package/dist/config/RebalancerConfig.js +9 -4
  8. package/dist/config/RebalancerConfig.js.map +1 -1
  9. package/dist/config/RebalancerConfig.test.js +135 -1
  10. package/dist/config/RebalancerConfig.test.js.map +1 -1
  11. package/dist/config/types.d.ts +1023 -304
  12. package/dist/config/types.d.ts.map +1 -1
  13. package/dist/config/types.js +113 -10
  14. package/dist/config/types.js.map +1 -1
  15. package/dist/core/InventoryRebalancer.d.ts +190 -0
  16. package/dist/core/InventoryRebalancer.d.ts.map +1 -0
  17. package/dist/core/InventoryRebalancer.js +892 -0
  18. package/dist/core/InventoryRebalancer.js.map +1 -0
  19. package/dist/core/InventoryRebalancer.test.d.ts +2 -0
  20. package/dist/core/InventoryRebalancer.test.d.ts.map +1 -0
  21. package/dist/core/InventoryRebalancer.test.js +1382 -0
  22. package/dist/core/InventoryRebalancer.test.js.map +1 -0
  23. package/dist/core/Rebalancer.d.ts +11 -4
  24. package/dist/core/Rebalancer.d.ts.map +1 -1
  25. package/dist/core/Rebalancer.js +92 -9
  26. package/dist/core/Rebalancer.js.map +1 -1
  27. package/dist/core/Rebalancer.test.js +82 -49
  28. package/dist/core/Rebalancer.test.js.map +1 -1
  29. package/dist/core/RebalancerOrchestrator.d.ts +30 -9
  30. package/dist/core/RebalancerOrchestrator.d.ts.map +1 -1
  31. package/dist/core/RebalancerOrchestrator.js +79 -71
  32. package/dist/core/RebalancerOrchestrator.js.map +1 -1
  33. package/dist/core/RebalancerOrchestrator.test.d.ts +2 -0
  34. package/dist/core/RebalancerOrchestrator.test.d.ts.map +1 -0
  35. package/dist/core/RebalancerOrchestrator.test.js +719 -0
  36. package/dist/core/RebalancerOrchestrator.test.js.map +1 -0
  37. package/dist/core/RebalancerService.d.ts +7 -3
  38. package/dist/core/RebalancerService.d.ts.map +1 -1
  39. package/dist/core/RebalancerService.js +44 -24
  40. package/dist/core/RebalancerService.js.map +1 -1
  41. package/dist/core/RebalancerService.test.js +74 -110
  42. package/dist/core/RebalancerService.test.js.map +1 -1
  43. package/dist/e2e/collateral-deficit.e2e-test.js +1 -3
  44. package/dist/e2e/collateral-deficit.e2e-test.js.map +1 -1
  45. package/dist/e2e/composite.e2e-test.js.map +1 -1
  46. package/dist/e2e/harness/BridgeSetup.d.ts +6 -0
  47. package/dist/e2e/harness/BridgeSetup.d.ts.map +1 -1
  48. package/dist/e2e/harness/BridgeSetup.js +10 -1
  49. package/dist/e2e/harness/BridgeSetup.js.map +1 -1
  50. package/dist/e2e/harness/ForkIndexer.d.ts.map +1 -1
  51. package/dist/e2e/harness/ForkIndexer.js +1 -0
  52. package/dist/e2e/harness/ForkIndexer.js.map +1 -1
  53. package/dist/e2e/harness/TestHelpers.d.ts.map +1 -1
  54. package/dist/e2e/harness/TestHelpers.js +1 -4
  55. package/dist/e2e/harness/TestHelpers.js.map +1 -1
  56. package/dist/e2e/harness/TestRebalancer.d.ts +1 -1
  57. package/dist/e2e/harness/TestRebalancer.d.ts.map +1 -1
  58. package/dist/e2e/harness/TestRebalancer.js +9 -9
  59. package/dist/e2e/harness/TestRebalancer.js.map +1 -1
  60. package/dist/e2e/minAmount.e2e-test.js +0 -1
  61. package/dist/e2e/minAmount.e2e-test.js.map +1 -1
  62. package/dist/e2e/weighted.e2e-test.js +0 -1
  63. package/dist/e2e/weighted.e2e-test.js.map +1 -1
  64. package/dist/factories/RebalancerContextFactory.d.ts +48 -6
  65. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  66. package/dist/factories/RebalancerContextFactory.js +171 -17
  67. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  68. package/dist/index.d.ts +6 -6
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +2 -2
  71. package/dist/index.js.map +1 -1
  72. package/dist/interfaces/IExternalBridge.d.ts +101 -0
  73. package/dist/interfaces/IExternalBridge.d.ts.map +1 -0
  74. package/dist/interfaces/IExternalBridge.js +2 -0
  75. package/dist/interfaces/IExternalBridge.js.map +1 -0
  76. package/dist/interfaces/IMonitor.d.ts +1 -0
  77. package/dist/interfaces/IMonitor.d.ts.map +1 -1
  78. package/dist/interfaces/IRebalancer.d.ts +25 -25
  79. package/dist/interfaces/IRebalancer.d.ts.map +1 -1
  80. package/dist/interfaces/IStrategy.d.ts +36 -3
  81. package/dist/interfaces/IStrategy.d.ts.map +1 -1
  82. package/dist/interfaces/IStrategy.js +12 -1
  83. package/dist/interfaces/IStrategy.js.map +1 -1
  84. package/dist/metrics/PriceGetter.js +1 -1
  85. package/dist/metrics/PriceGetter.js.map +1 -1
  86. package/dist/metrics/scripts/metrics.d.ts +3 -3
  87. package/dist/monitor/Monitor.d.ts +12 -2
  88. package/dist/monitor/Monitor.d.ts.map +1 -1
  89. package/dist/monitor/Monitor.js +46 -1
  90. package/dist/monitor/Monitor.js.map +1 -1
  91. package/dist/service.js +40 -17
  92. package/dist/service.js.map +1 -1
  93. package/dist/strategy/BaseStrategy.d.ts +12 -6
  94. package/dist/strategy/BaseStrategy.d.ts.map +1 -1
  95. package/dist/strategy/BaseStrategy.js +56 -21
  96. package/dist/strategy/BaseStrategy.js.map +1 -1
  97. package/dist/strategy/CollateralDeficitStrategy.d.ts +1 -1
  98. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
  99. package/dist/strategy/CollateralDeficitStrategy.js +19 -11
  100. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
  101. package/dist/strategy/CollateralDeficitStrategy.test.js +135 -2
  102. package/dist/strategy/CollateralDeficitStrategy.test.js.map +1 -1
  103. package/dist/strategy/CompositeStrategy.test.js +13 -0
  104. package/dist/strategy/CompositeStrategy.test.js.map +1 -1
  105. package/dist/strategy/MinAmountStrategy.test.js +4 -0
  106. package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
  107. package/dist/strategy/StrategyFactory.d.ts +2 -1
  108. package/dist/strategy/StrategyFactory.d.ts.map +1 -1
  109. package/dist/strategy/StrategyFactory.js +24 -8
  110. package/dist/strategy/StrategyFactory.js.map +1 -1
  111. package/dist/strategy/WeightedStrategy.test.js +6 -0
  112. package/dist/strategy/WeightedStrategy.test.js.map +1 -1
  113. package/dist/test/helpers.d.ts +8 -7
  114. package/dist/test/helpers.d.ts.map +1 -1
  115. package/dist/test/helpers.js +23 -5
  116. package/dist/test/helpers.js.map +1 -1
  117. package/dist/test/lifiMocks.d.ts +51 -0
  118. package/dist/test/lifiMocks.d.ts.map +1 -0
  119. package/dist/test/lifiMocks.js +130 -0
  120. package/dist/test/lifiMocks.js.map +1 -0
  121. package/dist/tracking/ActionTracker.d.ts +34 -1
  122. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  123. package/dist/tracking/ActionTracker.js +233 -26
  124. package/dist/tracking/ActionTracker.js.map +1 -1
  125. package/dist/tracking/ActionTracker.test.js +380 -19
  126. package/dist/tracking/ActionTracker.test.js.map +1 -1
  127. package/dist/tracking/IActionTracker.d.ts +48 -3
  128. package/dist/tracking/IActionTracker.d.ts.map +1 -1
  129. package/dist/tracking/InflightContextAdapter.d.ts.map +1 -1
  130. package/dist/tracking/InflightContextAdapter.js +24 -7
  131. package/dist/tracking/InflightContextAdapter.js.map +1 -1
  132. package/dist/tracking/InflightContextAdapter.test.js +7 -4
  133. package/dist/tracking/InflightContextAdapter.test.js.map +1 -1
  134. package/dist/tracking/types.d.ts +33 -2
  135. package/dist/tracking/types.d.ts.map +1 -1
  136. package/dist/utils/ExplorerClient.d.ts +3 -1
  137. package/dist/utils/ExplorerClient.d.ts.map +1 -1
  138. package/dist/utils/ExplorerClient.js +16 -8
  139. package/dist/utils/ExplorerClient.js.map +1 -1
  140. package/dist/utils/bridgeUtils.d.ts +27 -4
  141. package/dist/utils/bridgeUtils.d.ts.map +1 -1
  142. package/dist/utils/bridgeUtils.js +38 -0
  143. package/dist/utils/bridgeUtils.js.map +1 -1
  144. package/dist/utils/bridgeUtils.test.js +9 -0
  145. package/dist/utils/bridgeUtils.test.js.map +1 -1
  146. package/dist/utils/gasEstimation.d.ts +65 -0
  147. package/dist/utils/gasEstimation.d.ts.map +1 -0
  148. package/dist/utils/gasEstimation.js +176 -0
  149. package/dist/utils/gasEstimation.js.map +1 -0
  150. package/dist/utils/tokenUtils.d.ts +9 -1
  151. package/dist/utils/tokenUtils.d.ts.map +1 -1
  152. package/dist/utils/tokenUtils.js +11 -0
  153. package/dist/utils/tokenUtils.js.map +1 -1
  154. package/package.json +9 -7
  155. package/src/bridges/LiFiBridge.ts +538 -0
  156. package/src/config/RebalancerConfig.test.ts +162 -0
  157. package/src/config/RebalancerConfig.ts +21 -3
  158. package/src/config/types.ts +147 -10
  159. package/src/core/InventoryRebalancer.test.ts +1721 -0
  160. package/src/core/InventoryRebalancer.ts +1265 -0
  161. package/src/core/Rebalancer.test.ts +84 -30
  162. package/src/core/Rebalancer.ts +144 -23
  163. package/src/core/RebalancerOrchestrator.test.ts +869 -0
  164. package/src/core/RebalancerOrchestrator.ts +146 -95
  165. package/src/core/RebalancerService.test.ts +86 -124
  166. package/src/core/RebalancerService.ts +67 -33
  167. package/src/e2e/collateral-deficit.e2e-test.ts +2 -4
  168. package/src/e2e/composite.e2e-test.ts +5 -5
  169. package/src/e2e/harness/BridgeSetup.ts +28 -1
  170. package/src/e2e/harness/ForkIndexer.ts +1 -0
  171. package/src/e2e/harness/TestHelpers.ts +1 -4
  172. package/src/e2e/harness/TestRebalancer.ts +10 -7
  173. package/src/e2e/minAmount.e2e-test.ts +1 -2
  174. package/src/e2e/weighted.e2e-test.ts +1 -2
  175. package/src/factories/RebalancerContextFactory.ts +294 -24
  176. package/src/index.ts +22 -5
  177. package/src/interfaces/IExternalBridge.ts +115 -0
  178. package/src/interfaces/IMonitor.ts +1 -0
  179. package/src/interfaces/IRebalancer.ts +45 -29
  180. package/src/interfaces/IStrategy.ts +50 -3
  181. package/src/metrics/PriceGetter.ts +1 -1
  182. package/src/monitor/Monitor.ts +81 -2
  183. package/src/service.ts +59 -18
  184. package/src/strategy/BaseStrategy.ts +77 -24
  185. package/src/strategy/CollateralDeficitStrategy.test.ts +181 -4
  186. package/src/strategy/CollateralDeficitStrategy.ts +42 -15
  187. package/src/strategy/CompositeStrategy.test.ts +13 -0
  188. package/src/strategy/MinAmountStrategy.test.ts +4 -0
  189. package/src/strategy/StrategyFactory.ts +33 -6
  190. package/src/strategy/WeightedStrategy.test.ts +6 -0
  191. package/src/test/helpers.ts +39 -14
  192. package/src/test/lifiMocks.ts +174 -0
  193. package/src/tracking/ActionTracker.test.ts +443 -19
  194. package/src/tracking/ActionTracker.ts +339 -28
  195. package/src/tracking/IActionTracker.ts +59 -3
  196. package/src/tracking/InflightContextAdapter.test.ts +7 -4
  197. package/src/tracking/InflightContextAdapter.ts +42 -9
  198. package/src/tracking/types.ts +45 -2
  199. package/src/utils/ExplorerClient.ts +27 -10
  200. package/src/utils/bridgeUtils.test.ts +9 -0
  201. package/src/utils/bridgeUtils.ts +75 -6
  202. package/src/utils/gasEstimation.ts +272 -0
  203. package/src/utils/tokenUtils.ts +12 -0
  204. package/dist/tracking/index.d.ts +0 -7
  205. package/dist/tracking/index.d.ts.map +0 -1
  206. package/dist/tracking/index.js +0 -6
  207. package/dist/tracking/index.js.map +0 -1
  208. package/dist/utils/index.d.ts +0 -5
  209. package/dist/utils/index.d.ts.map +0 -1
  210. package/dist/utils/index.js +0 -5
  211. package/dist/utils/index.js.map +0 -1
  212. package/src/tracking/index.ts +0 -36
  213. package/src/utils/index.ts +0 -4
@@ -3,8 +3,9 @@ import { v4 as uuidv4 } from 'uuid';
3
3
 
4
4
  import type { MultiProtocolCore } from '@hyperlane-xyz/sdk';
5
5
  import type { Address, Domain } from '@hyperlane-xyz/utils';
6
- import { parseWarpRouteMessage } from '@hyperlane-xyz/utils';
6
+ import { assert, parseWarpRouteMessage } from '@hyperlane-xyz/utils';
7
7
 
8
+ import type { ExternalBridgeRegistry } from '../interfaces/IExternalBridge.js';
8
9
  import type { ConfirmedBlockTags } from '../interfaces/IMonitor.js';
9
10
  import type {
10
11
  ExplorerMessage,
@@ -18,9 +19,11 @@ import type {
18
19
  IActionTracker,
19
20
  } from './IActionTracker.js';
20
21
  import type {
22
+ ActionType,
21
23
  IRebalanceActionStore,
22
24
  IRebalanceIntentStore,
23
25
  ITransferStore,
26
+ PartialInventoryIntent,
24
27
  RebalanceAction,
25
28
  RebalanceIntent,
26
29
  Transfer,
@@ -30,6 +33,8 @@ export interface ActionTrackerConfig {
30
33
  routersByDomain: Record<number, string>; // Domain ID → router address (source of truth for routers and domains)
31
34
  bridges: Address[]; // Bridge contract addresses for rebalance action queries
32
35
  rebalancerAddress: Address;
36
+ inventorySignerAddress?: Address; // Optional - for excluding inventory signer from user transfers query
37
+ intentTTL: number; // Max age in ms before in-progress intent is expired
33
38
  }
34
39
 
35
40
  /**
@@ -68,6 +73,7 @@ export class ActionTracker implements IActionTracker {
68
73
  bridges: this.config.bridges,
69
74
  routersByDomain: this.config.routersByDomain,
70
75
  rebalancerAddress: this.config.rebalancerAddress,
76
+ inventorySignerAddress: this.config.inventorySignerAddress,
71
77
  },
72
78
  this.logger,
73
79
  );
@@ -98,10 +104,16 @@ export class ActionTracker implements IActionTracker {
98
104
  async syncTransfers(confirmedBlockTags?: ConfirmedBlockTags): Promise<void> {
99
105
  this.logger.debug('Syncing transfers');
100
106
 
107
+ // Build list of addresses to exclude (rebalancer + optional inventory signer)
108
+ const excludeTxSenders = [this.config.rebalancerAddress];
109
+ if (this.config.inventorySignerAddress) {
110
+ excludeTxSenders.push(this.config.inventorySignerAddress);
111
+ }
112
+
101
113
  const inflightMessages = await this.explorerClient.getInflightUserTransfers(
102
114
  {
103
115
  routersByDomain: this.config.routersByDomain,
104
- excludeTxSender: this.config.rebalancerAddress,
116
+ excludeTxSenders,
105
117
  },
106
118
  this.logger,
107
119
  );
@@ -200,15 +212,52 @@ export class ActionTracker implements IActionTracker {
200
212
  async syncRebalanceIntents(): Promise<void> {
201
213
  this.logger.debug('Syncing rebalance intents');
202
214
 
203
- // Check in_progress intents for completion
215
+ // Check in_progress intents for completion or TTL expiry
204
216
  const inProgressIntents =
205
217
  await this.rebalanceIntentStore.getByStatus('in_progress');
218
+ const allInProgressActions =
219
+ await this.rebalanceActionStore.getByStatus('in_progress');
220
+ const now = Date.now();
206
221
  for (const intent of inProgressIntents) {
207
- if (intent.fulfilledAmount >= intent.amount) {
222
+ const completedAmount = await this.getCompletedAmountForIntent(intent.id);
223
+ if (completedAmount >= intent.amount) {
208
224
  await this.rebalanceIntentStore.update(intent.id, {
209
225
  status: 'complete',
210
226
  });
211
227
  this.logger.debug({ id: intent.id }, 'RebalanceIntent completed');
228
+ } else if (now - intent.createdAt > this.config.intentTTL) {
229
+ await this.rebalanceIntentStore.update(intent.id, {
230
+ status: 'failed',
231
+ });
232
+
233
+ // Fail any in-progress actions associated with the expired intent
234
+ for (const action of allInProgressActions) {
235
+ if (action.intentId === intent.id) {
236
+ await this.rebalanceActionStore.update(action.id, {
237
+ status: 'failed',
238
+ });
239
+ this.logger.warn(
240
+ { actionId: action.id, intentId: intent.id },
241
+ 'RebalanceAction failed due to parent intent TTL expiry',
242
+ );
243
+ }
244
+ }
245
+
246
+ this.logger.debug(
247
+ {
248
+ id: intent.id,
249
+ origin: intent.origin,
250
+ destination: intent.destination,
251
+ amount: intent.amount.toString(),
252
+ ageMs: now - intent.createdAt,
253
+ ttlMs: this.config.intentTTL,
254
+ },
255
+ 'RebalanceIntent TTL expiry details',
256
+ );
257
+ this.logger.warn(
258
+ { id: intent.id },
259
+ 'RebalanceIntent expired due to TTL',
260
+ );
212
261
  }
213
262
  }
214
263
 
@@ -229,6 +278,7 @@ export class ActionTracker implements IActionTracker {
229
278
  bridges: this.config.bridges,
230
279
  routersByDomain: this.config.routersByDomain,
231
280
  rebalancerAddress: this.config.rebalancerAddress,
281
+ inventorySignerAddress: this.config.inventorySignerAddress,
232
282
  },
233
283
  this.logger,
234
284
  );
@@ -257,9 +307,17 @@ export class ActionTracker implements IActionTracker {
257
307
  }
258
308
  }
259
309
 
310
+ // Check delivery status for all in-progress actions in our store
311
+ // Only check delivery for actions that have a messageId (rebalance_message, inventory_deposit)
312
+ // inventory_movement actions are synced separately via LiFi status API
260
313
  const inProgressActions =
261
314
  await this.rebalanceActionStore.getByStatus('in_progress');
262
315
  for (const action of inProgressActions) {
316
+ // Skip actions without messageId (e.g., inventory_movement)
317
+ if (!action.messageId) {
318
+ continue;
319
+ }
320
+
263
321
  const blockTag = await this.getConfirmedBlockTag(
264
322
  action.destination,
265
323
  confirmedBlockTags,
@@ -333,17 +391,23 @@ export class ActionTracker implements IActionTracker {
333
391
  origin: params.origin,
334
392
  destination: params.destination,
335
393
  amount: params.amount,
336
- fulfilledAmount: 0n,
337
394
  bridge: params.bridge,
338
395
  priority: params.priority,
339
396
  strategyType: params.strategyType,
397
+ executionMethod: params.executionMethod,
398
+ externalBridge: params.externalBridge,
340
399
  createdAt: Date.now(),
341
400
  updatedAt: Date.now(),
342
401
  };
343
402
 
344
403
  await this.rebalanceIntentStore.save(intent);
345
404
  this.logger.debug(
346
- { id: intent.id, origin: intent.origin, destination: intent.destination },
405
+ {
406
+ id: intent.id,
407
+ origin: intent.origin,
408
+ destination: intent.destination,
409
+ executionMethod: intent.executionMethod,
410
+ },
347
411
  'Created RebalanceIntent',
348
412
  );
349
413
 
@@ -383,9 +447,12 @@ export class ActionTracker implements IActionTracker {
383
447
  const action: RebalanceAction = {
384
448
  id: uuidv4(),
385
449
  status: 'in_progress',
450
+ type: params.type,
386
451
  intentId: params.intentId,
387
452
  messageId: params.messageId,
388
453
  txHash: params.txHash,
454
+ externalBridgeTransferId: params.externalBridgeTransferId,
455
+ externalBridgeId: params.externalBridgeId,
389
456
  origin: params.origin,
390
457
  destination: params.destination,
391
458
  amount: params.amount,
@@ -408,7 +475,7 @@ export class ActionTracker implements IActionTracker {
408
475
  }
409
476
 
410
477
  this.logger.debug(
411
- { id: action.id, intentId: action.intentId },
478
+ { id: action.id, intentId: action.intentId, type: action.type },
412
479
  'Created RebalanceAction',
413
480
  );
414
481
 
@@ -423,32 +490,266 @@ export class ActionTracker implements IActionTracker {
423
490
 
424
491
  await this.rebalanceActionStore.update(id, { status: 'complete' });
425
492
 
426
- // Update parent intent's fulfilledAmount
427
- const intent = await this.rebalanceIntentStore.get(action.intentId);
428
- if (intent) {
429
- const newFulfilledAmount = intent.fulfilledAmount + action.amount;
430
- const updates: Partial<RebalanceIntent> = {
431
- fulfilledAmount: newFulfilledAmount,
432
- };
493
+ // Check if parent intent is now complete (derive from action states)
494
+ await this.checkAndCompleteIntent(action.intentId);
495
+
496
+ this.logger.info(
497
+ { id, intentId: action.intentId, type: action.type },
498
+ 'Action completed',
499
+ );
500
+ }
501
+
502
+ /**
503
+ * Check if an intent is fully fulfilled based on completed action amounts.
504
+ * Only `inventory_deposit` and `rebalance_message` actions count toward fulfillment.
505
+ */
506
+ private async checkAndCompleteIntent(intentId: string): Promise<void> {
507
+ const intent = await this.rebalanceIntentStore.get(intentId);
508
+ if (!intent || intent.status === 'complete') return;
509
+
510
+ const completedAmount = await this.getCompletedAmountForIntent(intentId);
511
+
512
+ if (completedAmount >= intent.amount) {
513
+ await this.rebalanceIntentStore.update(intentId, { status: 'complete' });
514
+ this.logger.debug(
515
+ { intentId, completedAmount: completedAmount.toString() },
516
+ 'RebalanceIntent fully fulfilled',
517
+ );
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Get the total completed amount for an intent from its actions.
523
+ * Only `inventory_deposit` and `rebalance_message` actions count.
524
+ */
525
+ private async getCompletedAmountForIntent(intentId: string): Promise<bigint> {
526
+ const actions = await this.getActionsForIntent(intentId);
527
+ return actions
528
+ .filter(
529
+ (a) =>
530
+ a.status === 'complete' &&
531
+ (a.type === 'inventory_deposit' || a.type === 'rebalance_message'),
532
+ )
533
+ .reduce((sum, a) => sum + a.amount, 0n);
534
+ }
535
+
536
+ async failRebalanceAction(id: string): Promise<void> {
537
+ await this.rebalanceActionStore.update(id, { status: 'failed' });
538
+ this.logger.info({ id }, 'Action failed');
539
+ }
540
+
541
+ // === RebalanceAction Queries ===
433
542
 
434
- // Check if intent is now complete
435
- if (newFulfilledAmount >= intent.amount) {
436
- updates.status = 'complete';
543
+ async getActionsByType(type: ActionType): Promise<RebalanceAction[]> {
544
+ const allActions = await this.rebalanceActionStore.getAll();
545
+ return allActions.filter((action) => action.type === type);
546
+ }
547
+
548
+ async getInflightInventoryMovements(origin: Domain): Promise<bigint> {
549
+ const allActions = await this.rebalanceActionStore.getAll();
550
+ const inflightMovements = allActions.filter(
551
+ (action) =>
552
+ action.type === 'inventory_movement' &&
553
+ action.status === 'in_progress' &&
554
+ action.origin === origin,
555
+ );
556
+
557
+ return inflightMovements.reduce(
558
+ (sum, action) => sum + action.amount,
559
+ BigInt(0),
560
+ );
561
+ }
562
+
563
+ /**
564
+ * Get inventory intents that are in_progress or not_started but not fully settled.
565
+ * Intents with in-flight deposits are included but flagged via hasInflightDeposit.
566
+ * Returns enriched data with computed values derived from action states.
567
+ *
568
+ * NOTE: We include 'not_started' intents because they may have been created
569
+ * but failed to execute (e.g., all bridges failed viability check). Without
570
+ * checking for these, we would create duplicate intents every polling cycle.
571
+ */
572
+ async getPartiallyFulfilledInventoryIntents(): Promise<
573
+ PartialInventoryIntent[]
574
+ > {
575
+ // Query both in_progress AND not_started intents
576
+ // not_started intents may exist if execution failed before any action was created
577
+ const [inProgressIntents, notStartedIntents] = await Promise.all([
578
+ this.rebalanceIntentStore.getByStatus('in_progress'),
579
+ this.rebalanceIntentStore.getByStatus('not_started'),
580
+ ]);
581
+
582
+ const allActiveIntents = [...inProgressIntents, ...notStartedIntents];
583
+ const partialIntents: PartialInventoryIntent[] = [];
584
+
585
+ for (const intent of allActiveIntents) {
586
+ // Only inventory execution method
587
+ if (intent.executionMethod !== 'inventory') continue;
588
+
589
+ const actions = await this.getActionsForIntent(intent.id);
590
+
591
+ // Check for in-flight inventory_movement actions
592
+ // Skip intents that have a bridge in progress - wait for it to complete
593
+ const hasInflightMovement = actions.some(
594
+ (a) => a.status === 'in_progress' && a.type === 'inventory_movement',
595
+ );
596
+
597
+ if (hasInflightMovement) {
437
598
  this.logger.debug(
438
599
  { intentId: intent.id },
439
- 'RebalanceIntent fully fulfilled',
600
+ 'Skipping partial intent - has in-flight inventory movement',
440
601
  );
602
+ continue;
441
603
  }
442
604
 
443
- await this.rebalanceIntentStore.update(intent.id, updates);
605
+ // Compute amounts from action states
606
+ const completedAmount = actions
607
+ .filter(
608
+ (a) => a.status === 'complete' && a.type === 'inventory_deposit',
609
+ )
610
+ .reduce((sum, a) => sum + a.amount, 0n);
611
+
612
+ const inflightAmount = actions
613
+ .filter(
614
+ (a) => a.status === 'in_progress' && a.type === 'inventory_deposit',
615
+ )
616
+ .reduce((sum, a) => sum + a.amount, 0n);
617
+
618
+ const remaining = intent.amount - completedAmount - inflightAmount;
619
+
620
+ if (remaining > 0n || inflightAmount > 0n) {
621
+ partialIntents.push({
622
+ intent,
623
+ completedAmount,
624
+ remaining,
625
+ hasInflightDeposit: inflightAmount > 0n,
626
+ });
627
+ }
444
628
  }
445
629
 
446
- this.logger.info({ id, intentId: action.intentId }, 'Action completed');
630
+ return partialIntents;
447
631
  }
448
632
 
449
- async failRebalanceAction(id: string): Promise<void> {
450
- await this.rebalanceActionStore.update(id, { status: 'failed' });
451
- this.logger.info({ id }, 'Action failed');
633
+ /**
634
+ * Get all actions associated with a specific intent.
635
+ */
636
+ async getActionsForIntent(intentId: string): Promise<RebalanceAction[]> {
637
+ const allActions = await this.rebalanceActionStore.getAll();
638
+ return allActions.filter((a) => a.intentId === intentId);
639
+ }
640
+
641
+ async syncInventoryMovementActions(
642
+ externalBridgeRegistry: Partial<ExternalBridgeRegistry>,
643
+ ): Promise<{ completed: number; failed: number }> {
644
+ this.logger.debug('Syncing inventory movement actions');
645
+
646
+ let completed = 0;
647
+ let failed = 0;
648
+
649
+ // Get all in-progress inventory_movement actions
650
+ const inProgressActions =
651
+ await this.rebalanceActionStore.getByStatus('in_progress');
652
+ const inventoryMovements = inProgressActions.filter(
653
+ (a) => a.type === 'inventory_movement',
654
+ );
655
+
656
+ this.logger.debug(
657
+ { count: inventoryMovements.length },
658
+ 'Found in-progress inventory movements',
659
+ );
660
+
661
+ for (const action of inventoryMovements) {
662
+ // Skip if no txHash (shouldn't happen but be safe)
663
+ if (!action.txHash) {
664
+ this.logger.warn(
665
+ { actionId: action.id },
666
+ 'Inventory movement action has no txHash',
667
+ );
668
+ continue;
669
+ }
670
+
671
+ // Skip if no externalBridgeId (shouldn't happen but be safe)
672
+ if (!action.externalBridgeId) {
673
+ this.logger.warn(
674
+ { actionId: action.id },
675
+ 'Inventory movement action has no externalBridgeId',
676
+ );
677
+ continue;
678
+ }
679
+
680
+ const externalBridge = externalBridgeRegistry[action.externalBridgeId];
681
+ if (!externalBridge) {
682
+ this.logger.warn(
683
+ { actionId: action.id, bridgeId: action.externalBridgeId },
684
+ 'Bridge not found in registry',
685
+ );
686
+ continue;
687
+ }
688
+
689
+ try {
690
+ const status = await externalBridge.getStatus(
691
+ action.txHash,
692
+ action.origin,
693
+ action.destination,
694
+ );
695
+
696
+ if (status.status === 'complete') {
697
+ await this.completeRebalanceAction(action.id);
698
+ completed++;
699
+ this.logger.info(
700
+ {
701
+ actionId: action.id,
702
+ txHash: action.txHash,
703
+ receivedAmount: status.receivedAmount?.toString(),
704
+ },
705
+ 'Inventory movement completed',
706
+ );
707
+ } else if (status.status === 'failed') {
708
+ await this.failRebalanceAction(action.id);
709
+ failed++;
710
+ this.logger.warn(
711
+ {
712
+ actionId: action.id,
713
+ txHash: action.txHash,
714
+ error: status.error,
715
+ },
716
+ 'Inventory movement failed',
717
+ );
718
+ } else if (status.status === 'pending') {
719
+ this.logger.debug(
720
+ {
721
+ actionId: action.id,
722
+ txHash: action.txHash,
723
+ substatus: status.substatus,
724
+ },
725
+ 'Inventory movement still pending',
726
+ );
727
+ }
728
+ // status === 'not_found' - wait for next cycle
729
+ } catch (error) {
730
+ this.logger.debug(
731
+ {
732
+ actionId: action.id,
733
+ txHash: action.txHash,
734
+ error: (error as Error).message,
735
+ },
736
+ 'Failed to get inventory movement status',
737
+ );
738
+ }
739
+ }
740
+
741
+ if (inventoryMovements.length > 0) {
742
+ this.logger.info(
743
+ {
744
+ completed,
745
+ failed,
746
+ pending: inventoryMovements.length - completed - failed,
747
+ },
748
+ 'Inventory movements synced',
749
+ );
750
+ }
751
+
752
+ return { completed, failed };
452
753
  }
453
754
 
454
755
  // === Debug Helpers ===
@@ -603,36 +904,46 @@ export class ActionTracker implements IActionTracker {
603
904
  try {
604
905
  // Create synthetic intent
605
906
  const { amount } = parseWarpRouteMessage(msg.message_body);
907
+ // Hasura returns block timestamps as UTC without 'Z' suffix (e.g. "2024-01-15T12:30:45").
908
+ // Null when the scraper hasn't indexed the origin block yet, so fall back to now.
909
+ // Note: when null, TTL effectively extends by scraper lag since recoverAction won't update createdAt later.
910
+ const createdAt = msg.send_occurred_at
911
+ ? new Date(msg.send_occurred_at + 'Z').getTime()
912
+ : Date.now();
913
+ assert(
914
+ !isNaN(createdAt),
915
+ `Invalid send_occurred_at timestamp: ${msg.send_occurred_at}`,
916
+ );
606
917
  const intent: RebalanceIntent = {
607
918
  id: uuidv4(),
608
919
  status: 'in_progress',
609
920
  origin: msg.origin_domain_id,
610
921
  destination: msg.destination_domain_id,
611
922
  amount,
612
- fulfilledAmount: 0n,
613
923
  priority: undefined,
614
924
  strategyType: undefined,
615
- createdAt: Date.now(),
925
+ createdAt,
616
926
  updatedAt: Date.now(),
617
927
  };
618
928
 
619
929
  await this.rebalanceIntentStore.save(intent);
620
930
  this.logger.debug(
621
- { id: intent.id, amount: amount.toString() },
931
+ { id: intent.id, amount: amount.toString(), createdAt },
622
932
  'Created synthetic RebalanceIntent',
623
933
  );
624
934
 
625
- // Create action
935
+ // Create action (recovered actions are always rebalance_message type)
626
936
  const action: RebalanceAction = {
627
937
  id: msg.msg_id,
628
938
  status: 'in_progress',
939
+ type: 'rebalance_message',
629
940
  intentId: intent.id,
630
941
  messageId: msg.msg_id,
631
942
  txHash: msg.origin_tx_hash,
632
943
  origin: msg.origin_domain_id,
633
944
  destination: msg.destination_domain_id,
634
945
  amount,
635
- createdAt: Date.now(),
946
+ createdAt,
636
947
  updatedAt: Date.now(),
637
948
  };
638
949
 
@@ -1,8 +1,17 @@
1
1
  import type { Address, Domain } from '@hyperlane-xyz/utils';
2
2
 
3
+ import type { ExternalBridgeType } from '../config/types.js';
4
+ import type { ExternalBridgeRegistry } from '../interfaces/IExternalBridge.js';
3
5
  import type { ConfirmedBlockTags } from '../interfaces/IMonitor.js';
4
6
 
5
- import type { RebalanceAction, RebalanceIntent, Transfer } from './types.js';
7
+ import type {
8
+ ActionType,
9
+ ExecutionMethod,
10
+ PartialInventoryIntent,
11
+ RebalanceAction,
12
+ RebalanceIntent,
13
+ Transfer,
14
+ } from './types.js';
6
15
 
7
16
  export interface CreateRebalanceIntentParams {
8
17
  origin: Domain;
@@ -11,6 +20,8 @@ export interface CreateRebalanceIntentParams {
11
20
  bridge?: Address;
12
21
  priority?: number;
13
22
  strategyType?: string;
23
+ executionMethod?: ExecutionMethod;
24
+ externalBridge?: ExternalBridgeType;
14
25
  }
15
26
 
16
27
  export interface CreateRebalanceActionParams {
@@ -18,8 +29,11 @@ export interface CreateRebalanceActionParams {
18
29
  origin: Domain;
19
30
  destination: Domain;
20
31
  amount: bigint;
21
- messageId: string;
32
+ type: ActionType; // Required - type of action being created
33
+ messageId?: string; // Optional - not needed for inventory_movement
22
34
  txHash?: string;
35
+ externalBridgeTransferId?: string; // Optional - for inventory_movement (external transfer bridge ID)
36
+ externalBridgeId?: ExternalBridgeType; // Optional - for inventory_movement (e.g., 'lifi')
23
37
  }
24
38
 
25
39
  /**
@@ -56,6 +70,18 @@ export interface IActionTracker {
56
70
  */
57
71
  syncRebalanceActions(confirmedBlockTags?: ConfirmedBlockTags): Promise<void>;
58
72
 
73
+ /**
74
+ * Sync inventory_movement actions by checking their status via external bridge API.
75
+ * This is separate from syncRebalanceActions because inventory_movement actions
76
+ * don't use Hyperlane messages and need to query the bridge's status API.
77
+ *
78
+ * @param externalBridgeRegistry - Bridge registry to query for status
79
+ * @returns Count of completed and failed actions
80
+ */
81
+ syncInventoryMovementActions(
82
+ externalBridgeRegistry: Partial<ExternalBridgeRegistry>,
83
+ ): Promise<{ completed: number; failed: number }>;
84
+
59
85
  // === Transfer Queries ===
60
86
 
61
87
  /**
@@ -92,6 +118,14 @@ export interface IActionTracker {
92
118
  destination: Domain,
93
119
  ): Promise<RebalanceIntent[]>;
94
120
 
121
+ /**
122
+ * Returns inventory intents that have remaining work.
123
+ * Intents with in-flight deposits are included but marked via hasInflightDeposit —
124
+ * callers must check before continuing.
125
+ * Returns enriched data with computed values derived from action states.
126
+ */
127
+ getPartiallyFulfilledInventoryIntents(): Promise<PartialInventoryIntent[]>;
128
+
95
129
  // === RebalanceIntent Management ===
96
130
 
97
131
  /**
@@ -121,6 +155,28 @@ export interface IActionTracker {
121
155
 
122
156
  // === RebalanceAction Queries ===
123
157
 
158
+ /**
159
+ * Get actions filtered by type.
160
+ * @param type - Action type to filter by
161
+ */
162
+ getActionsByType(type: ActionType): Promise<RebalanceAction[]>;
163
+
164
+ /**
165
+ * Get all actions associated with a specific intent.
166
+ * @param intentId - ID of the intent
167
+ */
168
+ getActionsForIntent(intentId: string): Promise<RebalanceAction[]>;
169
+
170
+ /**
171
+ * Get total inflight inventory movement amount from a specific chain.
172
+ * Returns the sum of amounts for all in_progress inventory_movement actions
173
+ * that originate from the specified domain.
174
+ *
175
+ * @param origin - Domain ID of the origin chain
176
+ * @returns Total amount being moved out via inventory movements
177
+ */
178
+ getInflightInventoryMovements(origin: Domain): Promise<bigint>;
179
+
124
180
  /**
125
181
  * Get a single rebalance action by ID.
126
182
  */
@@ -139,7 +195,7 @@ export interface IActionTracker {
139
195
 
140
196
  /**
141
197
  * Mark a rebalance action as complete.
142
- * Updates parent intent's fulfilledAmount.
198
+ * Checks if parent intent is now fully fulfilled and marks it complete if so.
143
199
  */
144
200
  completeRebalanceAction(id: string): Promise<void>;
145
201
 
@@ -16,6 +16,7 @@ describe('InflightContextAdapter', () => {
16
16
  actionTracker = {
17
17
  getActiveRebalanceIntents: Sinon.stub(),
18
18
  getInProgressTransfers: Sinon.stub(),
19
+ getActionsForIntent: Sinon.stub(),
19
20
  } as any;
20
21
 
21
22
  multiProvider = {
@@ -40,7 +41,6 @@ describe('InflightContextAdapter', () => {
40
41
  origin: 1,
41
42
  destination: 2,
42
43
  amount: 1000n,
43
- fulfilledAmount: 0n,
44
44
  status: 'not_started',
45
45
  createdAt: Date.now(),
46
46
  updatedAt: Date.now(),
@@ -64,6 +64,7 @@ describe('InflightContextAdapter', () => {
64
64
 
65
65
  actionTracker.getActiveRebalanceIntents.resolves(mockIntents);
66
66
  actionTracker.getInProgressTransfers.resolves(mockTransfers);
67
+ actionTracker.getActionsForIntent.resolves([]); // No actions
67
68
  multiProvider.getChainName.withArgs(1).returns('ethereum');
68
69
  multiProvider.getChainName.withArgs(2).returns('arbitrum');
69
70
 
@@ -74,6 +75,9 @@ describe('InflightContextAdapter', () => {
74
75
  origin: 'ethereum',
75
76
  destination: 'arbitrum',
76
77
  amount: 1000n,
78
+ deliveredAmount: 0n,
79
+ awaitingDeliveryAmount: 0n,
80
+ executionMethod: undefined,
77
81
  bridge: undefined,
78
82
  });
79
83
 
@@ -102,7 +106,6 @@ describe('InflightContextAdapter', () => {
102
106
  origin: 137,
103
107
  destination: 10,
104
108
  amount: 2000n,
105
- fulfilledAmount: 0n,
106
109
  status: 'not_started',
107
110
  createdAt: Date.now(),
108
111
  updatedAt: Date.now(),
@@ -126,6 +129,7 @@ describe('InflightContextAdapter', () => {
126
129
 
127
130
  actionTracker.getActiveRebalanceIntents.resolves(mockIntents);
128
131
  actionTracker.getInProgressTransfers.resolves(mockTransfers);
132
+ actionTracker.getActionsForIntent.resolves([]);
129
133
  multiProvider.getChainName.withArgs(137).returns('polygon');
130
134
  multiProvider.getChainName.withArgs(10).returns('optimism');
131
135
 
@@ -144,7 +148,6 @@ describe('InflightContextAdapter', () => {
144
148
  origin: 1,
145
149
  destination: 2,
146
150
  amount: 1000n,
147
- fulfilledAmount: 0n,
148
151
  status: 'not_started',
149
152
  createdAt: Date.now(),
150
153
  updatedAt: Date.now(),
@@ -154,7 +157,6 @@ describe('InflightContextAdapter', () => {
154
157
  origin: 2,
155
158
  destination: 3,
156
159
  amount: 1500n,
157
- fulfilledAmount: 0n,
158
160
  status: 'in_progress',
159
161
  createdAt: Date.now(),
160
162
  updatedAt: Date.now(),
@@ -190,6 +192,7 @@ describe('InflightContextAdapter', () => {
190
192
 
191
193
  actionTracker.getActiveRebalanceIntents.resolves(mockIntents);
192
194
  actionTracker.getInProgressTransfers.resolves(mockTransfers);
195
+ actionTracker.getActionsForIntent.resolves([]);
193
196
  multiProvider.getChainName.withArgs(1).returns('ethereum');
194
197
  multiProvider.getChainName.withArgs(2).returns('arbitrum');
195
198
  multiProvider.getChainName.withArgs(3).returns('optimism');