@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,6 +3,7 @@ import chaiAsPromised from 'chai-as-promised';
3
3
  import { pino } from 'pino';
4
4
  import Sinon from 'sinon';
5
5
  import { EthJsonRpcBlockParameterTag } from '@hyperlane-xyz/sdk';
6
+ import { DEFAULT_INTENT_TTL_MS } from '../config/types.js';
6
7
  import { ActionTracker } from './ActionTracker.js';
7
8
  import { InMemoryStore } from './store/InMemoryStore.js';
8
9
  chai.use(chaiAsPromised);
@@ -48,6 +49,7 @@ describe('ActionTracker', () => {
48
49
  },
49
50
  bridges: ['0xbridge1', '0xbridge2'],
50
51
  rebalancerAddress: '0xrebalancer',
52
+ intentTTL: DEFAULT_INTENT_TTL_MS,
51
53
  };
52
54
  tracker = new ActionTracker(transferStore, rebalanceIntentStore, rebalanceActionStore, explorerClient, core, config, testLogger);
53
55
  });
@@ -65,6 +67,7 @@ describe('ActionTracker', () => {
65
67
  origin_tx_recipient: '0xrouter1',
66
68
  is_delivered: false,
67
69
  message_body: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
70
+ send_occurred_at: null,
68
71
  },
69
72
  ];
70
73
  explorerClient.getInflightRebalanceActions.resolves(inflightMessages);
@@ -87,6 +90,86 @@ describe('ActionTracker', () => {
87
90
  expect(actions[0].status).to.equal('in_progress');
88
91
  expect(actions[0].messageId).to.equal('0xmsg1');
89
92
  });
93
+ it('should use send_occurred_at for createdAt when available', async () => {
94
+ const inflightMessages = [
95
+ {
96
+ msg_id: '0xmsg1',
97
+ origin_domain_id: 1,
98
+ destination_domain_id: 2,
99
+ sender: '0xrouter1',
100
+ recipient: '0xrouter2',
101
+ origin_tx_hash: '0xtx1',
102
+ origin_tx_sender: '0xrebalancer',
103
+ origin_tx_recipient: '0xrouter1',
104
+ is_delivered: false,
105
+ message_body: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
106
+ send_occurred_at: '2024-01-15T12:30:45',
107
+ },
108
+ ];
109
+ explorerClient.getInflightRebalanceActions.resolves(inflightMessages);
110
+ explorerClient.getInflightUserTransfers.resolves([]);
111
+ mailboxStub.isDelivered.resolves(false);
112
+ await tracker.initialize();
113
+ const intents = await rebalanceIntentStore.getAll();
114
+ expect(intents).to.have.lengthOf(1);
115
+ // Hasura timestamps are UTC without 'Z'; recoverAction appends 'Z' before parsing
116
+ const expectedMs = new Date('2024-01-15T12:30:45Z').getTime();
117
+ expect(intents[0].createdAt).to.equal(expectedMs);
118
+ const actions = await rebalanceActionStore.getAll();
119
+ expect(actions[0].createdAt).to.equal(expectedMs);
120
+ });
121
+ it('should fall back to Date.now() when send_occurred_at is null', async () => {
122
+ const before = Date.now();
123
+ const inflightMessages = [
124
+ {
125
+ msg_id: '0xmsg1',
126
+ origin_domain_id: 1,
127
+ destination_domain_id: 2,
128
+ sender: '0xrouter1',
129
+ recipient: '0xrouter2',
130
+ origin_tx_hash: '0xtx1',
131
+ origin_tx_sender: '0xrebalancer',
132
+ origin_tx_recipient: '0xrouter1',
133
+ is_delivered: false,
134
+ message_body: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
135
+ send_occurred_at: null,
136
+ },
137
+ ];
138
+ explorerClient.getInflightRebalanceActions.resolves(inflightMessages);
139
+ explorerClient.getInflightUserTransfers.resolves([]);
140
+ mailboxStub.isDelivered.resolves(false);
141
+ await tracker.initialize();
142
+ const after = Date.now();
143
+ const intents = await rebalanceIntentStore.getAll();
144
+ expect(intents[0].createdAt).to.be.at.least(before);
145
+ expect(intents[0].createdAt).to.be.at.most(after);
146
+ });
147
+ it('should skip action with invalid send_occurred_at timestamp', async () => {
148
+ const inflightMessages = [
149
+ {
150
+ msg_id: '0xmsg1',
151
+ origin_domain_id: 1,
152
+ destination_domain_id: 2,
153
+ sender: '0xrouter1',
154
+ recipient: '0xrouter2',
155
+ origin_tx_hash: '0xtx1',
156
+ origin_tx_sender: '0xrebalancer',
157
+ origin_tx_recipient: '0xrouter1',
158
+ is_delivered: false,
159
+ message_body: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
160
+ send_occurred_at: 'garbage',
161
+ },
162
+ ];
163
+ explorerClient.getInflightRebalanceActions.resolves(inflightMessages);
164
+ explorerClient.getInflightUserTransfers.resolves([]);
165
+ mailboxStub.isDelivered.resolves(false);
166
+ await tracker.initialize();
167
+ // Invalid timestamp is caught by recoverAction's catch block, so the action is skipped
168
+ const intents = await rebalanceIntentStore.getAll();
169
+ expect(intents).to.have.lengthOf(0);
170
+ const actions = await rebalanceActionStore.getAll();
171
+ expect(actions).to.have.lengthOf(0);
172
+ });
90
173
  it('should skip creating action if it already exists', async () => {
91
174
  const inflightMessages = [
92
175
  {
@@ -100,11 +183,13 @@ describe('ActionTracker', () => {
100
183
  origin_tx_recipient: '0xrouter1',
101
184
  is_delivered: false,
102
185
  message_body: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
186
+ send_occurred_at: null,
103
187
  },
104
188
  ];
105
189
  // Pre-create action
106
190
  await rebalanceActionStore.save({
107
191
  id: '0xmsg1',
192
+ type: 'rebalance_message',
108
193
  status: 'in_progress',
109
194
  intentId: 'existing-intent',
110
195
  messageId: '0xmsg1',
@@ -139,6 +224,7 @@ describe('ActionTracker', () => {
139
224
  origin_tx_recipient: '0xrouter1',
140
225
  is_delivered: false,
141
226
  message_body: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
227
+ send_occurred_at: null,
142
228
  },
143
229
  ];
144
230
  explorerClient.getInflightUserTransfers.resolves(inflightMessages);
@@ -176,6 +262,7 @@ describe('ActionTracker', () => {
176
262
  origin_tx_recipient: '0xrouter1',
177
263
  is_delivered: false,
178
264
  message_body: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
265
+ send_occurred_at: null,
179
266
  },
180
267
  ];
181
268
  explorerClient.getInflightUserTransfers.resolves(inflightMessages);
@@ -206,33 +293,104 @@ describe('ActionTracker', () => {
206
293
  });
207
294
  describe('syncRebalanceIntents', () => {
208
295
  it('should mark intents as complete when fully fulfilled', async () => {
296
+ // Intent derives completion from action states, so we need a complete action
209
297
  const intent = {
210
298
  id: 'intent-1',
211
299
  status: 'in_progress',
212
300
  origin: 1,
213
301
  destination: 2,
214
302
  amount: 100n,
215
- fulfilledAmount: 100n,
303
+ createdAt: Date.now(),
304
+ updatedAt: Date.now(),
305
+ };
306
+ const action = {
307
+ id: 'action-1',
308
+ type: 'rebalance_message',
309
+ status: 'complete',
310
+ intentId: 'intent-1',
311
+ messageId: '0xmsg1',
312
+ origin: 1,
313
+ destination: 2,
314
+ amount: 100n,
216
315
  createdAt: Date.now(),
217
316
  updatedAt: Date.now(),
218
317
  };
219
318
  await rebalanceIntentStore.save(intent);
319
+ await rebalanceActionStore.save(action);
220
320
  await tracker.syncRebalanceIntents();
221
321
  const updated = await rebalanceIntentStore.get('intent-1');
222
322
  expect(updated?.status).to.equal('complete');
223
323
  });
224
324
  it('should not mark intents as complete if not fully fulfilled', async () => {
325
+ // Intent with only partial completion via actions
326
+ const intent = {
327
+ id: 'intent-1',
328
+ status: 'in_progress',
329
+ origin: 1,
330
+ destination: 2,
331
+ amount: 100n,
332
+ createdAt: Date.now(),
333
+ updatedAt: Date.now(),
334
+ };
335
+ const action = {
336
+ id: 'action-1',
337
+ type: 'rebalance_message',
338
+ status: 'complete',
339
+ intentId: 'intent-1',
340
+ messageId: '0xmsg1',
341
+ origin: 1,
342
+ destination: 2,
343
+ amount: 50n, // Only partial
344
+ createdAt: Date.now(),
345
+ updatedAt: Date.now(),
346
+ };
347
+ await rebalanceIntentStore.save(intent);
348
+ await rebalanceActionStore.save(action);
349
+ await tracker.syncRebalanceIntents();
350
+ const updated = await rebalanceIntentStore.get('intent-1');
351
+ expect(updated?.status).to.equal('in_progress');
352
+ });
353
+ it('should mark unfulfilled intents as failed when TTL exceeded', async () => {
225
354
  const intent = {
226
355
  id: 'intent-1',
227
356
  status: 'in_progress',
228
357
  origin: 1,
229
358
  destination: 2,
230
359
  amount: 100n,
231
- fulfilledAmount: 50n,
360
+ createdAt: Date.now() - DEFAULT_INTENT_TTL_MS - 1,
361
+ updatedAt: Date.now(),
362
+ };
363
+ const action = {
364
+ id: 'action-1',
365
+ type: 'rebalance_message',
366
+ status: 'in_progress',
367
+ intentId: 'intent-1',
368
+ messageId: '0xmsg1',
369
+ origin: 1,
370
+ destination: 2,
371
+ amount: 50n,
232
372
  createdAt: Date.now(),
233
373
  updatedAt: Date.now(),
234
374
  };
235
375
  await rebalanceIntentStore.save(intent);
376
+ await rebalanceActionStore.save(action);
377
+ await tracker.syncRebalanceIntents();
378
+ const updatedIntent = await rebalanceIntentStore.get('intent-1');
379
+ expect(updatedIntent?.status).to.equal('failed');
380
+ const updatedAction = await rebalanceActionStore.get('action-1');
381
+ expect(updatedAction?.status).to.equal('failed');
382
+ });
383
+ it('should not expire intents within TTL', async () => {
384
+ const intent = {
385
+ id: 'intent-1',
386
+ status: 'in_progress',
387
+ origin: 1,
388
+ destination: 2,
389
+ amount: 100n,
390
+ createdAt: Date.now() - DEFAULT_INTENT_TTL_MS + 60_000,
391
+ updatedAt: Date.now(),
392
+ };
393
+ await rebalanceIntentStore.save(intent);
236
394
  await tracker.syncRebalanceIntents();
237
395
  const updated = await rebalanceIntentStore.get('intent-1');
238
396
  expect(updated?.status).to.equal('in_progress');
@@ -246,12 +404,12 @@ describe('ActionTracker', () => {
246
404
  origin: 1,
247
405
  destination: 2,
248
406
  amount: 100n,
249
- fulfilledAmount: 0n,
250
407
  createdAt: Date.now(),
251
408
  updatedAt: Date.now(),
252
409
  };
253
410
  const action = {
254
411
  id: 'action-1',
412
+ type: 'rebalance_message',
255
413
  status: 'in_progress',
256
414
  intentId: 'intent-1',
257
415
  messageId: '0xmsg1',
@@ -268,14 +426,14 @@ describe('ActionTracker', () => {
268
426
  // Action should be complete
269
427
  const updatedAction = await rebalanceActionStore.get('action-1');
270
428
  expect(updatedAction?.status).to.equal('complete');
271
- // Intent should be updated and complete
429
+ // Intent should be complete (derived from completed action amounts)
272
430
  const updatedIntent = await rebalanceIntentStore.get('intent-1');
273
- expect(updatedIntent?.fulfilledAmount).to.equal(100n);
274
431
  expect(updatedIntent?.status).to.equal('complete');
275
432
  });
276
433
  it('should not mark actions as complete if not delivered', async () => {
277
434
  const action = {
278
435
  id: 'action-1',
436
+ type: 'rebalance_message',
279
437
  status: 'in_progress',
280
438
  intentId: 'intent-1',
281
439
  messageId: '0xmsg1',
@@ -331,7 +489,6 @@ describe('ActionTracker', () => {
331
489
  origin: 1,
332
490
  destination: 2,
333
491
  amount: 100n,
334
- fulfilledAmount: 0n,
335
492
  createdAt: Date.now(),
336
493
  updatedAt: Date.now(),
337
494
  });
@@ -341,7 +498,6 @@ describe('ActionTracker', () => {
341
498
  origin: 2,
342
499
  destination: 3,
343
500
  amount: 200n,
344
- fulfilledAmount: 50n,
345
501
  createdAt: Date.now(),
346
502
  updatedAt: Date.now(),
347
503
  });
@@ -351,7 +507,6 @@ describe('ActionTracker', () => {
351
507
  origin: 3,
352
508
  destination: 1,
353
509
  amount: 300n,
354
- fulfilledAmount: 300n,
355
510
  createdAt: Date.now(),
356
511
  updatedAt: Date.now(),
357
512
  });
@@ -362,6 +517,214 @@ describe('ActionTracker', () => {
362
517
  expect(result[0].id).to.equal('intent-2');
363
518
  });
364
519
  });
520
+ describe('getPartiallyFulfilledInventoryIntents', () => {
521
+ it('returns not_started inventory intents', async () => {
522
+ // Create a not_started inventory intent (simulates failed execution before any action created)
523
+ await rebalanceIntentStore.save({
524
+ id: 'stuck-intent',
525
+ status: 'not_started',
526
+ origin: 1,
527
+ destination: 2,
528
+ amount: 1000000000000000000n, // 1 ETH
529
+ executionMethod: 'inventory',
530
+ createdAt: Date.now(),
531
+ updatedAt: Date.now(),
532
+ });
533
+ // Should be returned even though status is 'not_started'
534
+ const partialIntents = await tracker.getPartiallyFulfilledInventoryIntents();
535
+ expect(partialIntents).to.have.lengthOf(1);
536
+ expect(partialIntents[0].intent.id).to.equal('stuck-intent');
537
+ expect(partialIntents[0].completedAmount).to.equal(0n);
538
+ expect(partialIntents[0].remaining).to.equal(1000000000000000000n);
539
+ });
540
+ it('returns in_progress inventory intents with partial completion', async () => {
541
+ // Create an in_progress inventory intent with a completed action
542
+ await rebalanceIntentStore.save({
543
+ id: 'partial-intent',
544
+ status: 'in_progress',
545
+ origin: 1,
546
+ destination: 2,
547
+ amount: 1000000000000000000n, // 1 ETH
548
+ executionMethod: 'inventory',
549
+ createdAt: Date.now(),
550
+ updatedAt: Date.now(),
551
+ });
552
+ // Create a completed inventory_deposit action for partial amount
553
+ await rebalanceActionStore.save({
554
+ id: 'action-1',
555
+ type: 'inventory_deposit',
556
+ status: 'complete',
557
+ intentId: 'partial-intent',
558
+ origin: 1,
559
+ destination: 2,
560
+ amount: 400000000000000000n, // 0.4 ETH completed
561
+ createdAt: Date.now(),
562
+ updatedAt: Date.now(),
563
+ });
564
+ const partialIntents = await tracker.getPartiallyFulfilledInventoryIntents();
565
+ expect(partialIntents).to.have.lengthOf(1);
566
+ expect(partialIntents[0].intent.id).to.equal('partial-intent');
567
+ expect(partialIntents[0].completedAmount).to.equal(400000000000000000n);
568
+ expect(partialIntents[0].remaining).to.equal(600000000000000000n); // 0.6 ETH remaining
569
+ });
570
+ it('does not return non-inventory intents', async () => {
571
+ // Create a not_started intent without executionMethod: 'inventory'
572
+ await rebalanceIntentStore.save({
573
+ id: 'non-inventory-intent',
574
+ status: 'not_started',
575
+ origin: 1,
576
+ destination: 2,
577
+ amount: 1000000000000000000n,
578
+ // executionMethod is undefined - not an inventory intent
579
+ createdAt: Date.now(),
580
+ updatedAt: Date.now(),
581
+ });
582
+ const partialIntents = await tracker.getPartiallyFulfilledInventoryIntents();
583
+ expect(partialIntents).to.have.lengthOf(0);
584
+ });
585
+ it('returns intent with in-flight deposit and sets hasInflightDeposit flag', async () => {
586
+ // Setup: in_progress inventory intent (amount: 1 ETH = 1_000_000_000_000_000_000n)
587
+ await rebalanceIntentStore.save({
588
+ id: 'intent-with-inflight',
589
+ status: 'in_progress',
590
+ origin: 1,
591
+ destination: 2,
592
+ amount: 1000000000000000000n, // 1 ETH
593
+ executionMethod: 'inventory',
594
+ createdAt: Date.now(),
595
+ updatedAt: Date.now(),
596
+ });
597
+ // Complete inventory_deposit action (amount: 400_000_000_000_000_000n = 0.4 ETH)
598
+ await rebalanceActionStore.save({
599
+ id: 'action-complete',
600
+ type: 'inventory_deposit',
601
+ status: 'complete',
602
+ intentId: 'intent-with-inflight',
603
+ origin: 1,
604
+ destination: 2,
605
+ amount: 400000000000000000n, // 0.4 ETH completed
606
+ createdAt: Date.now(),
607
+ updatedAt: Date.now(),
608
+ });
609
+ // In_progress inventory_deposit action (amount: 300_000_000_000_000_000n = 0.3 ETH)
610
+ await rebalanceActionStore.save({
611
+ id: 'action-inflight',
612
+ type: 'inventory_deposit',
613
+ status: 'in_progress',
614
+ intentId: 'intent-with-inflight',
615
+ origin: 1,
616
+ destination: 2,
617
+ amount: 300000000000000000n, // 0.3 ETH in-flight
618
+ createdAt: Date.now(),
619
+ updatedAt: Date.now(),
620
+ });
621
+ const partialIntents = await tracker.getPartiallyFulfilledInventoryIntents();
622
+ expect(partialIntents).to.have.lengthOf(1);
623
+ expect(partialIntents[0].hasInflightDeposit).to.be.true;
624
+ expect(partialIntents[0].completedAmount).to.equal(400000000000000000n);
625
+ expect(partialIntents[0].remaining).to.equal(300000000000000000n); // 1.0 - 0.4 - 0.3
626
+ });
627
+ it('returns intent without in-flight deposit with hasInflightDeposit false', async () => {
628
+ // Setup: in_progress inventory intent (amount: 1 ETH)
629
+ await rebalanceIntentStore.save({
630
+ id: 'intent-no-inflight',
631
+ status: 'in_progress',
632
+ origin: 1,
633
+ destination: 2,
634
+ amount: 1000000000000000000n, // 1 ETH
635
+ executionMethod: 'inventory',
636
+ createdAt: Date.now(),
637
+ updatedAt: Date.now(),
638
+ });
639
+ // Complete inventory_deposit action (amount: 0.4 ETH)
640
+ await rebalanceActionStore.save({
641
+ id: 'action-complete-only',
642
+ type: 'inventory_deposit',
643
+ status: 'complete',
644
+ intentId: 'intent-no-inflight',
645
+ origin: 1,
646
+ destination: 2,
647
+ amount: 400000000000000000n, // 0.4 ETH completed
648
+ createdAt: Date.now(),
649
+ updatedAt: Date.now(),
650
+ });
651
+ // NO in_progress inventory_deposit actions
652
+ const partialIntents = await tracker.getPartiallyFulfilledInventoryIntents();
653
+ expect(partialIntents).to.have.lengthOf(1);
654
+ expect(partialIntents[0].hasInflightDeposit).to.be.false;
655
+ expect(partialIntents[0].remaining).to.equal(600000000000000000n);
656
+ });
657
+ it('returns intent when remaining is 0n but has in-flight deposit', async () => {
658
+ // intent.amount = 1 ETH, completedAmount = 0.7 ETH, inflightAmount = 0.3 ETH → remaining = 0n
659
+ await rebalanceIntentStore.save({
660
+ id: 'intent-zero-remaining',
661
+ status: 'in_progress',
662
+ origin: 1,
663
+ destination: 2,
664
+ amount: 1000000000000000000n, // 1 ETH
665
+ executionMethod: 'inventory',
666
+ createdAt: Date.now(),
667
+ updatedAt: Date.now(),
668
+ });
669
+ // 0.7 ETH already completed
670
+ await rebalanceActionStore.save({
671
+ id: 'action-complete-part',
672
+ type: 'inventory_deposit',
673
+ status: 'complete',
674
+ intentId: 'intent-zero-remaining',
675
+ origin: 1,
676
+ destination: 2,
677
+ amount: 700000000000000000n, // 0.7 ETH
678
+ createdAt: Date.now(),
679
+ updatedAt: Date.now(),
680
+ });
681
+ // 0.3 ETH in-flight — exactly fills the remaining gap
682
+ await rebalanceActionStore.save({
683
+ id: 'action-inflight-rest',
684
+ type: 'inventory_deposit',
685
+ status: 'in_progress',
686
+ intentId: 'intent-zero-remaining',
687
+ origin: 1,
688
+ destination: 2,
689
+ amount: 300000000000000000n, // 0.3 ETH in-flight
690
+ createdAt: Date.now(),
691
+ updatedAt: Date.now(),
692
+ });
693
+ const partialIntents = await tracker.getPartiallyFulfilledInventoryIntents();
694
+ expect(partialIntents).to.have.lengthOf(1);
695
+ expect(partialIntents[0].remaining).to.equal(0n);
696
+ expect(partialIntents[0].hasInflightDeposit).to.be.true;
697
+ expect(partialIntents[0].completedAmount).to.equal(700000000000000000n);
698
+ });
699
+ it('does not return fully completed intent with no in-flight deposits', async () => {
700
+ // intent.amount = 1 ETH, completedAmount = 1 ETH, inflightAmount = 0 → remaining = 0n, no inflight
701
+ await rebalanceIntentStore.save({
702
+ id: 'intent-fully-done',
703
+ status: 'in_progress',
704
+ origin: 1,
705
+ destination: 2,
706
+ amount: 1000000000000000000n, // 1 ETH
707
+ executionMethod: 'inventory',
708
+ createdAt: Date.now(),
709
+ updatedAt: Date.now(),
710
+ });
711
+ // Full amount completed
712
+ await rebalanceActionStore.save({
713
+ id: 'action-all-done',
714
+ type: 'inventory_deposit',
715
+ status: 'complete',
716
+ intentId: 'intent-fully-done',
717
+ origin: 1,
718
+ destination: 2,
719
+ amount: 1000000000000000000n, // 1 ETH — full amount
720
+ createdAt: Date.now(),
721
+ updatedAt: Date.now(),
722
+ });
723
+ const partialIntents = await tracker.getPartiallyFulfilledInventoryIntents();
724
+ // remaining = 0n AND inflightAmount = 0n → should NOT be returned
725
+ expect(partialIntents).to.have.lengthOf(0);
726
+ });
727
+ });
365
728
  describe('createRebalanceIntent', () => {
366
729
  it('should create a new intent with status not_started', async () => {
367
730
  const result = await tracker.createRebalanceIntent({
@@ -375,7 +738,6 @@ describe('ActionTracker', () => {
375
738
  expect(result.origin).to.equal(1);
376
739
  expect(result.destination).to.equal(2);
377
740
  expect(result.amount).to.equal(100n);
378
- expect(result.fulfilledAmount).to.equal(0n);
379
741
  expect(result.priority).to.equal(1);
380
742
  expect(result.strategyType).to.equal('MinAmountStrategy');
381
743
  const stored = await rebalanceIntentStore.get(result.id);
@@ -390,12 +752,12 @@ describe('ActionTracker', () => {
390
752
  origin: 1,
391
753
  destination: 2,
392
754
  amount: 100n,
393
- fulfilledAmount: 0n,
394
755
  createdAt: Date.now(),
395
756
  updatedAt: Date.now(),
396
757
  };
397
758
  await rebalanceIntentStore.save(intent);
398
759
  const result = await tracker.createRebalanceAction({
760
+ type: 'rebalance_message',
399
761
  intentId: 'intent-1',
400
762
  origin: 1,
401
763
  destination: 2,
@@ -416,12 +778,12 @@ describe('ActionTracker', () => {
416
778
  origin: 1,
417
779
  destination: 2,
418
780
  amount: 100n,
419
- fulfilledAmount: 50n,
420
781
  createdAt: Date.now(),
421
782
  updatedAt: Date.now(),
422
783
  };
423
784
  await rebalanceIntentStore.save(intent);
424
785
  await tracker.createRebalanceAction({
786
+ type: 'rebalance_message',
425
787
  intentId: 'intent-1',
426
788
  origin: 1,
427
789
  destination: 2,
@@ -431,23 +793,22 @@ describe('ActionTracker', () => {
431
793
  });
432
794
  const updatedIntent = await rebalanceIntentStore.get('intent-1');
433
795
  expect(updatedIntent?.status).to.equal('in_progress');
434
- expect(updatedIntent?.fulfilledAmount).to.equal(50n); // Should not change
435
796
  });
436
797
  });
437
798
  describe('completeRebalanceAction', () => {
438
- it('should mark action as complete and update parent intent fulfilledAmount', async () => {
799
+ it('should mark action as complete and mark parent intent complete if fully fulfilled', async () => {
439
800
  const intent = {
440
801
  id: 'intent-1',
441
802
  status: 'in_progress',
442
803
  origin: 1,
443
804
  destination: 2,
444
805
  amount: 100n,
445
- fulfilledAmount: 0n,
446
806
  createdAt: Date.now(),
447
807
  updatedAt: Date.now(),
448
808
  };
449
809
  const action = {
450
810
  id: 'action-1',
811
+ type: 'rebalance_message',
451
812
  status: 'in_progress',
452
813
  intentId: 'intent-1',
453
814
  messageId: '0xmsg1',
@@ -462,8 +823,8 @@ describe('ActionTracker', () => {
462
823
  await tracker.completeRebalanceAction('action-1');
463
824
  const updatedAction = await rebalanceActionStore.get('action-1');
464
825
  expect(updatedAction?.status).to.equal('complete');
826
+ // Intent should be complete (derived from completed action amounts)
465
827
  const updatedIntent = await rebalanceIntentStore.get('intent-1');
466
- expect(updatedIntent?.fulfilledAmount).to.equal(100n);
467
828
  expect(updatedIntent?.status).to.equal('complete');
468
829
  });
469
830
  it('should throw error when action not found', async () => {
@@ -478,7 +839,6 @@ describe('ActionTracker', () => {
478
839
  origin: 1,
479
840
  destination: 2,
480
841
  amount: 100n,
481
- fulfilledAmount: 0n,
482
842
  createdAt: Date.now(),
483
843
  updatedAt: Date.now(),
484
844
  };
@@ -492,6 +852,7 @@ describe('ActionTracker', () => {
492
852
  it('should mark action as failed', async () => {
493
853
  const action = {
494
854
  id: 'action-1',
855
+ type: 'rebalance_message',
495
856
  status: 'in_progress',
496
857
  intentId: 'intent-1',
497
858
  messageId: '0xmsg1',
@@ -527,7 +888,7 @@ describe('ActionTracker', () => {
527
888
  expect(call).to.not.be.null;
528
889
  const params = call.args[0];
529
890
  expect(params.routersByDomain).to.deep.equal(config.routersByDomain);
530
- expect(params.excludeTxSender).to.equal(config.rebalancerAddress);
891
+ expect(params.excludeTxSenders).to.deep.equal([config.rebalancerAddress]);
531
892
  });
532
893
  });
533
894
  describe('delivery check synchronization', () => {
@@ -560,12 +921,12 @@ describe('ActionTracker', () => {
560
921
  origin: 1,
561
922
  destination: 2,
562
923
  amount: 100n,
563
- fulfilledAmount: 0n,
564
924
  createdAt: Date.now(),
565
925
  updatedAt: Date.now(),
566
926
  };
567
927
  const action = {
568
928
  id: 'action-1',
929
+ type: 'rebalance_message',
569
930
  status: 'in_progress',
570
931
  intentId: 'intent-1',
571
932
  messageId: '0xmsg1',
@@ -655,13 +1016,13 @@ describe('ActionTracker', () => {
655
1016
  origin: 1,
656
1017
  destination: 2,
657
1018
  amount: 100n,
658
- fulfilledAmount: 0n,
659
1019
  createdAt: Date.now(),
660
1020
  updatedAt: Date.now(),
661
1021
  };
662
1022
  const action = {
663
1023
  id: 'action-1',
664
1024
  status: 'in_progress',
1025
+ type: 'rebalance_message',
665
1026
  intentId: 'intent-1',
666
1027
  messageId: '0xmsg1',
667
1028
  origin: 1,