@hyperlane-xyz/rebalancer 3.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 (63) hide show
  1. package/dist/config/RebalancerConfig.d.ts +2 -1
  2. package/dist/config/RebalancerConfig.d.ts.map +1 -1
  3. package/dist/config/RebalancerConfig.js +5 -3
  4. package/dist/config/RebalancerConfig.js.map +1 -1
  5. package/dist/config/RebalancerConfig.test.js +2 -1
  6. package/dist/config/RebalancerConfig.test.js.map +1 -1
  7. package/dist/config/types.d.ts +7 -0
  8. package/dist/config/types.d.ts.map +1 -1
  9. package/dist/config/types.js +8 -0
  10. package/dist/config/types.js.map +1 -1
  11. package/dist/core/InventoryRebalancer.d.ts.map +1 -1
  12. package/dist/core/InventoryRebalancer.js +7 -0
  13. package/dist/core/InventoryRebalancer.js.map +1 -1
  14. package/dist/core/InventoryRebalancer.test.js +31 -0
  15. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  16. package/dist/core/RebalancerOrchestrator.test.js +6 -1
  17. package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
  18. package/dist/core/RebalancerService.test.js +3 -1
  19. package/dist/core/RebalancerService.test.js.map +1 -1
  20. package/dist/e2e/harness/ForkIndexer.d.ts.map +1 -1
  21. package/dist/e2e/harness/ForkIndexer.js +1 -0
  22. package/dist/e2e/harness/ForkIndexer.js.map +1 -1
  23. package/dist/e2e/harness/TestRebalancer.d.ts.map +1 -1
  24. package/dist/e2e/harness/TestRebalancer.js +3 -2
  25. package/dist/e2e/harness/TestRebalancer.js.map +1 -1
  26. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  27. package/dist/factories/RebalancerContextFactory.js +1 -0
  28. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/tracking/ActionTracker.d.ts +3 -2
  34. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  35. package/dist/tracking/ActionTracker.js +46 -10
  36. package/dist/tracking/ActionTracker.js.map +1 -1
  37. package/dist/tracking/ActionTracker.test.js +273 -0
  38. package/dist/tracking/ActionTracker.test.js.map +1 -1
  39. package/dist/tracking/IActionTracker.d.ts +3 -2
  40. package/dist/tracking/IActionTracker.d.ts.map +1 -1
  41. package/dist/tracking/types.d.ts +3 -1
  42. package/dist/tracking/types.d.ts.map +1 -1
  43. package/dist/utils/ExplorerClient.d.ts +1 -0
  44. package/dist/utils/ExplorerClient.d.ts.map +1 -1
  45. package/dist/utils/ExplorerClient.js +3 -0
  46. package/dist/utils/ExplorerClient.js.map +1 -1
  47. package/package.json +7 -7
  48. package/src/config/RebalancerConfig.test.ts +2 -0
  49. package/src/config/RebalancerConfig.ts +9 -2
  50. package/src/config/types.ts +11 -0
  51. package/src/core/InventoryRebalancer.test.ts +37 -0
  52. package/src/core/InventoryRebalancer.ts +10 -0
  53. package/src/core/RebalancerOrchestrator.test.ts +10 -1
  54. package/src/core/RebalancerService.test.ts +6 -1
  55. package/src/e2e/harness/ForkIndexer.ts +1 -0
  56. package/src/e2e/harness/TestRebalancer.ts +3 -0
  57. package/src/factories/RebalancerContextFactory.ts +1 -0
  58. package/src/index.ts +2 -0
  59. package/src/tracking/ActionTracker.test.ts +321 -0
  60. package/src/tracking/ActionTracker.ts +61 -10
  61. package/src/tracking/IActionTracker.ts +3 -2
  62. package/src/tracking/types.ts +3 -1
  63. package/src/utils/ExplorerClient.ts +4 -0
@@ -5,6 +5,7 @@ import Sinon from 'sinon';
5
5
 
6
6
  import { EthJsonRpcBlockParameterTag } from '@hyperlane-xyz/sdk';
7
7
 
8
+ import { DEFAULT_INTENT_TTL_MS } from '../config/types.js';
8
9
  import type { ExplorerMessage } from '../utils/ExplorerClient.js';
9
10
 
10
11
  import { ActionTracker, type ActionTrackerConfig } from './ActionTracker.js';
@@ -71,6 +72,7 @@ describe('ActionTracker', () => {
71
72
  },
72
73
  bridges: ['0xbridge1', '0xbridge2'],
73
74
  rebalancerAddress: '0xrebalancer',
75
+ intentTTL: DEFAULT_INTENT_TTL_MS,
74
76
  };
75
77
 
76
78
  tracker = new ActionTracker(
@@ -99,6 +101,7 @@ describe('ActionTracker', () => {
99
101
  is_delivered: false,
100
102
  message_body:
101
103
  '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
104
+ send_occurred_at: null,
102
105
  },
103
106
  ];
104
107
 
@@ -128,6 +131,102 @@ describe('ActionTracker', () => {
128
131
  expect(actions[0].messageId).to.equal('0xmsg1');
129
132
  });
130
133
 
134
+ it('should use send_occurred_at for createdAt when available', async () => {
135
+ const inflightMessages: ExplorerMessage[] = [
136
+ {
137
+ msg_id: '0xmsg1',
138
+ origin_domain_id: 1,
139
+ destination_domain_id: 2,
140
+ sender: '0xrouter1',
141
+ recipient: '0xrouter2',
142
+ origin_tx_hash: '0xtx1',
143
+ origin_tx_sender: '0xrebalancer',
144
+ origin_tx_recipient: '0xrouter1',
145
+ is_delivered: false,
146
+ message_body:
147
+ '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
148
+ send_occurred_at: '2024-01-15T12:30:45',
149
+ },
150
+ ];
151
+
152
+ explorerClient.getInflightRebalanceActions.resolves(inflightMessages);
153
+ explorerClient.getInflightUserTransfers.resolves([]);
154
+ mailboxStub.isDelivered.resolves(false);
155
+
156
+ await tracker.initialize();
157
+
158
+ const intents = await rebalanceIntentStore.getAll();
159
+ expect(intents).to.have.lengthOf(1);
160
+ // Hasura timestamps are UTC without 'Z'; recoverAction appends 'Z' before parsing
161
+ const expectedMs = new Date('2024-01-15T12:30:45Z').getTime();
162
+ expect(intents[0].createdAt).to.equal(expectedMs);
163
+
164
+ const actions = await rebalanceActionStore.getAll();
165
+ expect(actions[0].createdAt).to.equal(expectedMs);
166
+ });
167
+
168
+ it('should fall back to Date.now() when send_occurred_at is null', async () => {
169
+ const before = Date.now();
170
+ const inflightMessages: ExplorerMessage[] = [
171
+ {
172
+ msg_id: '0xmsg1',
173
+ origin_domain_id: 1,
174
+ destination_domain_id: 2,
175
+ sender: '0xrouter1',
176
+ recipient: '0xrouter2',
177
+ origin_tx_hash: '0xtx1',
178
+ origin_tx_sender: '0xrebalancer',
179
+ origin_tx_recipient: '0xrouter1',
180
+ is_delivered: false,
181
+ message_body:
182
+ '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
183
+ send_occurred_at: null,
184
+ },
185
+ ];
186
+
187
+ explorerClient.getInflightRebalanceActions.resolves(inflightMessages);
188
+ explorerClient.getInflightUserTransfers.resolves([]);
189
+ mailboxStub.isDelivered.resolves(false);
190
+
191
+ await tracker.initialize();
192
+ const after = Date.now();
193
+
194
+ const intents = await rebalanceIntentStore.getAll();
195
+ expect(intents[0].createdAt).to.be.at.least(before);
196
+ expect(intents[0].createdAt).to.be.at.most(after);
197
+ });
198
+
199
+ it('should skip action with invalid send_occurred_at timestamp', async () => {
200
+ const inflightMessages: ExplorerMessage[] = [
201
+ {
202
+ msg_id: '0xmsg1',
203
+ origin_domain_id: 1,
204
+ destination_domain_id: 2,
205
+ sender: '0xrouter1',
206
+ recipient: '0xrouter2',
207
+ origin_tx_hash: '0xtx1',
208
+ origin_tx_sender: '0xrebalancer',
209
+ origin_tx_recipient: '0xrouter1',
210
+ is_delivered: false,
211
+ message_body:
212
+ '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
213
+ send_occurred_at: 'garbage',
214
+ },
215
+ ];
216
+
217
+ explorerClient.getInflightRebalanceActions.resolves(inflightMessages);
218
+ explorerClient.getInflightUserTransfers.resolves([]);
219
+ mailboxStub.isDelivered.resolves(false);
220
+
221
+ await tracker.initialize();
222
+
223
+ // Invalid timestamp is caught by recoverAction's catch block, so the action is skipped
224
+ const intents = await rebalanceIntentStore.getAll();
225
+ expect(intents).to.have.lengthOf(0);
226
+ const actions = await rebalanceActionStore.getAll();
227
+ expect(actions).to.have.lengthOf(0);
228
+ });
229
+
131
230
  it('should skip creating action if it already exists', async () => {
132
231
  const inflightMessages: ExplorerMessage[] = [
133
232
  {
@@ -142,6 +241,7 @@ describe('ActionTracker', () => {
142
241
  is_delivered: false,
143
242
  message_body:
144
243
  '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
244
+ send_occurred_at: null,
145
245
  },
146
246
  ];
147
247
 
@@ -189,6 +289,7 @@ describe('ActionTracker', () => {
189
289
  is_delivered: false,
190
290
  message_body:
191
291
  '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
292
+ send_occurred_at: null,
192
293
  },
193
294
  ];
194
295
 
@@ -232,6 +333,7 @@ describe('ActionTracker', () => {
232
333
  is_delivered: false,
233
334
  message_body:
234
335
  '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
336
+ send_occurred_at: null,
235
337
  },
236
338
  ];
237
339
 
@@ -336,6 +438,61 @@ describe('ActionTracker', () => {
336
438
  const updated = await rebalanceIntentStore.get('intent-1');
337
439
  expect(updated?.status).to.equal('in_progress');
338
440
  });
441
+
442
+ it('should mark unfulfilled intents as failed when TTL exceeded', async () => {
443
+ const intent: RebalanceIntent = {
444
+ id: 'intent-1',
445
+ status: 'in_progress',
446
+ origin: 1,
447
+ destination: 2,
448
+ amount: 100n,
449
+ createdAt: Date.now() - DEFAULT_INTENT_TTL_MS - 1,
450
+ updatedAt: Date.now(),
451
+ };
452
+
453
+ const action: RebalanceAction = {
454
+ id: 'action-1',
455
+ type: 'rebalance_message',
456
+ status: 'in_progress',
457
+ intentId: 'intent-1',
458
+ messageId: '0xmsg1',
459
+ origin: 1,
460
+ destination: 2,
461
+ amount: 50n,
462
+ createdAt: Date.now(),
463
+ updatedAt: Date.now(),
464
+ };
465
+
466
+ await rebalanceIntentStore.save(intent);
467
+ await rebalanceActionStore.save(action);
468
+
469
+ await tracker.syncRebalanceIntents();
470
+
471
+ const updatedIntent = await rebalanceIntentStore.get('intent-1');
472
+ expect(updatedIntent?.status).to.equal('failed');
473
+
474
+ const updatedAction = await rebalanceActionStore.get('action-1');
475
+ expect(updatedAction?.status).to.equal('failed');
476
+ });
477
+
478
+ it('should not expire intents within TTL', async () => {
479
+ const intent: RebalanceIntent = {
480
+ id: 'intent-1',
481
+ status: 'in_progress',
482
+ origin: 1,
483
+ destination: 2,
484
+ amount: 100n,
485
+ createdAt: Date.now() - DEFAULT_INTENT_TTL_MS + 60_000,
486
+ updatedAt: Date.now(),
487
+ };
488
+
489
+ await rebalanceIntentStore.save(intent);
490
+
491
+ await tracker.syncRebalanceIntents();
492
+
493
+ const updated = await rebalanceIntentStore.get('intent-1');
494
+ expect(updated?.status).to.equal('in_progress');
495
+ });
339
496
  });
340
497
 
341
498
  describe('syncRebalanceActions', () => {
@@ -555,6 +712,170 @@ describe('ActionTracker', () => {
555
712
 
556
713
  expect(partialIntents).to.have.lengthOf(0);
557
714
  });
715
+
716
+ it('returns intent with in-flight deposit and sets hasInflightDeposit flag', async () => {
717
+ // Setup: in_progress inventory intent (amount: 1 ETH = 1_000_000_000_000_000_000n)
718
+ await rebalanceIntentStore.save({
719
+ id: 'intent-with-inflight',
720
+ status: 'in_progress',
721
+ origin: 1,
722
+ destination: 2,
723
+ amount: 1000000000000000000n, // 1 ETH
724
+ executionMethod: 'inventory',
725
+ createdAt: Date.now(),
726
+ updatedAt: Date.now(),
727
+ });
728
+
729
+ // Complete inventory_deposit action (amount: 400_000_000_000_000_000n = 0.4 ETH)
730
+ await rebalanceActionStore.save({
731
+ id: 'action-complete',
732
+ type: 'inventory_deposit',
733
+ status: 'complete',
734
+ intentId: 'intent-with-inflight',
735
+ origin: 1,
736
+ destination: 2,
737
+ amount: 400000000000000000n, // 0.4 ETH completed
738
+ createdAt: Date.now(),
739
+ updatedAt: Date.now(),
740
+ });
741
+
742
+ // In_progress inventory_deposit action (amount: 300_000_000_000_000_000n = 0.3 ETH)
743
+ await rebalanceActionStore.save({
744
+ id: 'action-inflight',
745
+ type: 'inventory_deposit',
746
+ status: 'in_progress',
747
+ intentId: 'intent-with-inflight',
748
+ origin: 1,
749
+ destination: 2,
750
+ amount: 300000000000000000n, // 0.3 ETH in-flight
751
+ createdAt: Date.now(),
752
+ updatedAt: Date.now(),
753
+ });
754
+
755
+ const partialIntents =
756
+ await tracker.getPartiallyFulfilledInventoryIntents();
757
+
758
+ expect(partialIntents).to.have.lengthOf(1);
759
+ expect(partialIntents[0].hasInflightDeposit).to.be.true;
760
+ expect(partialIntents[0].completedAmount).to.equal(400000000000000000n);
761
+ expect(partialIntents[0].remaining).to.equal(300000000000000000n); // 1.0 - 0.4 - 0.3
762
+ });
763
+
764
+ it('returns intent without in-flight deposit with hasInflightDeposit false', async () => {
765
+ // Setup: in_progress inventory intent (amount: 1 ETH)
766
+ await rebalanceIntentStore.save({
767
+ id: 'intent-no-inflight',
768
+ status: 'in_progress',
769
+ origin: 1,
770
+ destination: 2,
771
+ amount: 1000000000000000000n, // 1 ETH
772
+ executionMethod: 'inventory',
773
+ createdAt: Date.now(),
774
+ updatedAt: Date.now(),
775
+ });
776
+
777
+ // Complete inventory_deposit action (amount: 0.4 ETH)
778
+ await rebalanceActionStore.save({
779
+ id: 'action-complete-only',
780
+ type: 'inventory_deposit',
781
+ status: 'complete',
782
+ intentId: 'intent-no-inflight',
783
+ origin: 1,
784
+ destination: 2,
785
+ amount: 400000000000000000n, // 0.4 ETH completed
786
+ createdAt: Date.now(),
787
+ updatedAt: Date.now(),
788
+ });
789
+ // NO in_progress inventory_deposit actions
790
+
791
+ const partialIntents =
792
+ await tracker.getPartiallyFulfilledInventoryIntents();
793
+
794
+ expect(partialIntents).to.have.lengthOf(1);
795
+ expect(partialIntents[0].hasInflightDeposit).to.be.false;
796
+ expect(partialIntents[0].remaining).to.equal(600000000000000000n);
797
+ });
798
+
799
+ it('returns intent when remaining is 0n but has in-flight deposit', async () => {
800
+ // intent.amount = 1 ETH, completedAmount = 0.7 ETH, inflightAmount = 0.3 ETH → remaining = 0n
801
+ await rebalanceIntentStore.save({
802
+ id: 'intent-zero-remaining',
803
+ status: 'in_progress',
804
+ origin: 1,
805
+ destination: 2,
806
+ amount: 1000000000000000000n, // 1 ETH
807
+ executionMethod: 'inventory',
808
+ createdAt: Date.now(),
809
+ updatedAt: Date.now(),
810
+ });
811
+
812
+ // 0.7 ETH already completed
813
+ await rebalanceActionStore.save({
814
+ id: 'action-complete-part',
815
+ type: 'inventory_deposit',
816
+ status: 'complete',
817
+ intentId: 'intent-zero-remaining',
818
+ origin: 1,
819
+ destination: 2,
820
+ amount: 700000000000000000n, // 0.7 ETH
821
+ createdAt: Date.now(),
822
+ updatedAt: Date.now(),
823
+ });
824
+
825
+ // 0.3 ETH in-flight — exactly fills the remaining gap
826
+ await rebalanceActionStore.save({
827
+ id: 'action-inflight-rest',
828
+ type: 'inventory_deposit',
829
+ status: 'in_progress',
830
+ intentId: 'intent-zero-remaining',
831
+ origin: 1,
832
+ destination: 2,
833
+ amount: 300000000000000000n, // 0.3 ETH in-flight
834
+ createdAt: Date.now(),
835
+ updatedAt: Date.now(),
836
+ });
837
+
838
+ const partialIntents =
839
+ await tracker.getPartiallyFulfilledInventoryIntents();
840
+
841
+ expect(partialIntents).to.have.lengthOf(1);
842
+ expect(partialIntents[0].remaining).to.equal(0n);
843
+ expect(partialIntents[0].hasInflightDeposit).to.be.true;
844
+ expect(partialIntents[0].completedAmount).to.equal(700000000000000000n);
845
+ });
846
+
847
+ it('does not return fully completed intent with no in-flight deposits', async () => {
848
+ // intent.amount = 1 ETH, completedAmount = 1 ETH, inflightAmount = 0 → remaining = 0n, no inflight
849
+ await rebalanceIntentStore.save({
850
+ id: 'intent-fully-done',
851
+ status: 'in_progress',
852
+ origin: 1,
853
+ destination: 2,
854
+ amount: 1000000000000000000n, // 1 ETH
855
+ executionMethod: 'inventory',
856
+ createdAt: Date.now(),
857
+ updatedAt: Date.now(),
858
+ });
859
+
860
+ // Full amount completed
861
+ await rebalanceActionStore.save({
862
+ id: 'action-all-done',
863
+ type: 'inventory_deposit',
864
+ status: 'complete',
865
+ intentId: 'intent-fully-done',
866
+ origin: 1,
867
+ destination: 2,
868
+ amount: 1000000000000000000n, // 1 ETH — full amount
869
+ createdAt: Date.now(),
870
+ updatedAt: Date.now(),
871
+ });
872
+
873
+ const partialIntents =
874
+ await tracker.getPartiallyFulfilledInventoryIntents();
875
+
876
+ // remaining = 0n AND inflightAmount = 0n → should NOT be returned
877
+ expect(partialIntents).to.have.lengthOf(0);
878
+ });
558
879
  });
559
880
 
560
881
  describe('createRebalanceIntent', () => {
@@ -3,7 +3,7 @@ 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
8
  import type { ExternalBridgeRegistry } from '../interfaces/IExternalBridge.js';
9
9
  import type { ConfirmedBlockTags } from '../interfaces/IMonitor.js';
@@ -34,6 +34,7 @@ export interface ActionTrackerConfig {
34
34
  bridges: Address[]; // Bridge contract addresses for rebalance action queries
35
35
  rebalancerAddress: Address;
36
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
37
38
  }
38
39
 
39
40
  /**
@@ -211,9 +212,12 @@ export class ActionTracker implements IActionTracker {
211
212
  async syncRebalanceIntents(): Promise<void> {
212
213
  this.logger.debug('Syncing rebalance intents');
213
214
 
214
- // Check in_progress intents for completion by deriving from action states
215
+ // Check in_progress intents for completion or TTL expiry
215
216
  const inProgressIntents =
216
217
  await this.rebalanceIntentStore.getByStatus('in_progress');
218
+ const allInProgressActions =
219
+ await this.rebalanceActionStore.getByStatus('in_progress');
220
+ const now = Date.now();
217
221
  for (const intent of inProgressIntents) {
218
222
  const completedAmount = await this.getCompletedAmountForIntent(intent.id);
219
223
  if (completedAmount >= intent.amount) {
@@ -221,6 +225,39 @@ export class ActionTracker implements IActionTracker {
221
225
  status: 'complete',
222
226
  });
223
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
+ );
224
261
  }
225
262
  }
226
263
 
@@ -524,8 +561,8 @@ export class ActionTracker implements IActionTracker {
524
561
  }
525
562
 
526
563
  /**
527
- * Get inventory intents that are in_progress or not_started but not fully fulfilled,
528
- * and have no in-flight actions (safe to continue).
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.
529
566
  * Returns enriched data with computed values derived from action states.
530
567
  *
531
568
  * NOTE: We include 'not_started' intents because they may have been created
@@ -580,9 +617,13 @@ export class ActionTracker implements IActionTracker {
580
617
 
581
618
  const remaining = intent.amount - completedAmount - inflightAmount;
582
619
 
583
- // Safe to continue if: remaining > 0 AND no in-flight inventory_deposit
584
- if (remaining > 0n && inflightAmount === 0n) {
585
- partialIntents.push({ intent, completedAmount, remaining });
620
+ if (remaining > 0n || inflightAmount > 0n) {
621
+ partialIntents.push({
622
+ intent,
623
+ completedAmount,
624
+ remaining,
625
+ hasInflightDeposit: inflightAmount > 0n,
626
+ });
586
627
  }
587
628
  }
588
629
 
@@ -863,6 +904,16 @@ export class ActionTracker implements IActionTracker {
863
904
  try {
864
905
  // Create synthetic intent
865
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
+ );
866
917
  const intent: RebalanceIntent = {
867
918
  id: uuidv4(),
868
919
  status: 'in_progress',
@@ -871,13 +922,13 @@ export class ActionTracker implements IActionTracker {
871
922
  amount,
872
923
  priority: undefined,
873
924
  strategyType: undefined,
874
- createdAt: Date.now(),
925
+ createdAt,
875
926
  updatedAt: Date.now(),
876
927
  };
877
928
 
878
929
  await this.rebalanceIntentStore.save(intent);
879
930
  this.logger.debug(
880
- { id: intent.id, amount: amount.toString() },
931
+ { id: intent.id, amount: amount.toString(), createdAt },
881
932
  'Created synthetic RebalanceIntent',
882
933
  );
883
934
 
@@ -892,7 +943,7 @@ export class ActionTracker implements IActionTracker {
892
943
  origin: msg.origin_domain_id,
893
944
  destination: msg.destination_domain_id,
894
945
  amount,
895
- createdAt: Date.now(),
946
+ createdAt,
896
947
  updatedAt: Date.now(),
897
948
  };
898
949
 
@@ -119,8 +119,9 @@ export interface IActionTracker {
119
119
  ): Promise<RebalanceIntent[]>;
120
120
 
121
121
  /**
122
- * Get inventory intents that are in_progress but not fully fulfilled,
123
- * and have no in-flight actions (safe to continue).
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.
124
125
  * Returns enriched data with computed values derived from action states.
125
126
  */
126
127
  getPartiallyFulfilledInventoryIntents(): Promise<PartialInventoryIntent[]>;
@@ -108,6 +108,8 @@ export interface PartialInventoryIntent {
108
108
  intent: RebalanceIntent;
109
109
  /** Sum of complete inventory_deposit action amounts */
110
110
  completedAmount: bigint;
111
- /** Amount remaining to fulfill: intent.amount - completedAmount - inflightAmount */
111
+ /** Amount remaining to fulfill (0n when final deposit is fully in-flight). Formula: intent.amount - completedAmount - inflightAmount */
112
112
  remaining: bigint;
113
+ /** True when intent has in_progress inventory_deposit actions (not safe to continue, but still active) */
114
+ hasInflightDeposit: boolean;
113
115
  }
@@ -32,6 +32,7 @@ export type ExplorerMessage = {
32
32
  origin_tx_recipient: string;
33
33
  is_delivered: boolean;
34
34
  message_body: string;
35
+ send_occurred_at: string | null;
35
36
  };
36
37
 
37
38
  export interface IExplorerClient {
@@ -72,6 +73,7 @@ export class ExplorerClient implements IExplorerClient {
72
73
  origin_tx_recipient: normalizeHex(msg.origin_tx_recipient),
73
74
  is_delivered: msg.is_delivered,
74
75
  message_body: normalizeHex(msg.message_body),
76
+ send_occurred_at: msg.send_occurred_at ?? null,
75
77
  };
76
78
  }
77
79
 
@@ -242,6 +244,7 @@ export class ExplorerClient implements IExplorerClient {
242
244
  origin_tx_recipient
243
245
  is_delivered
244
246
  message_body
247
+ send_occurred_at
245
248
  }
246
249
  }`;
247
250
 
@@ -350,6 +353,7 @@ export class ExplorerClient implements IExplorerClient {
350
353
  origin_tx_recipient
351
354
  is_delivered
352
355
  message_body
356
+ send_occurred_at
353
357
  }
354
358
  }`;
355
359