@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.
- package/dist/config/RebalancerConfig.d.ts +2 -1
- package/dist/config/RebalancerConfig.d.ts.map +1 -1
- package/dist/config/RebalancerConfig.js +5 -3
- package/dist/config/RebalancerConfig.js.map +1 -1
- package/dist/config/RebalancerConfig.test.js +2 -1
- package/dist/config/RebalancerConfig.test.js.map +1 -1
- package/dist/config/types.d.ts +7 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +8 -0
- package/dist/config/types.js.map +1 -1
- package/dist/core/InventoryRebalancer.d.ts.map +1 -1
- package/dist/core/InventoryRebalancer.js +7 -0
- package/dist/core/InventoryRebalancer.js.map +1 -1
- package/dist/core/InventoryRebalancer.test.js +31 -0
- package/dist/core/InventoryRebalancer.test.js.map +1 -1
- package/dist/core/RebalancerOrchestrator.test.js +6 -1
- package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
- package/dist/core/RebalancerService.test.js +3 -1
- package/dist/core/RebalancerService.test.js.map +1 -1
- package/dist/e2e/harness/ForkIndexer.d.ts.map +1 -1
- package/dist/e2e/harness/ForkIndexer.js +1 -0
- package/dist/e2e/harness/ForkIndexer.js.map +1 -1
- package/dist/e2e/harness/TestRebalancer.d.ts.map +1 -1
- package/dist/e2e/harness/TestRebalancer.js +3 -2
- package/dist/e2e/harness/TestRebalancer.js.map +1 -1
- package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
- package/dist/factories/RebalancerContextFactory.js +1 -0
- package/dist/factories/RebalancerContextFactory.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tracking/ActionTracker.d.ts +3 -2
- package/dist/tracking/ActionTracker.d.ts.map +1 -1
- package/dist/tracking/ActionTracker.js +46 -10
- package/dist/tracking/ActionTracker.js.map +1 -1
- package/dist/tracking/ActionTracker.test.js +273 -0
- package/dist/tracking/ActionTracker.test.js.map +1 -1
- package/dist/tracking/IActionTracker.d.ts +3 -2
- package/dist/tracking/IActionTracker.d.ts.map +1 -1
- package/dist/tracking/types.d.ts +3 -1
- package/dist/tracking/types.d.ts.map +1 -1
- package/dist/utils/ExplorerClient.d.ts +1 -0
- package/dist/utils/ExplorerClient.d.ts.map +1 -1
- package/dist/utils/ExplorerClient.js +3 -0
- package/dist/utils/ExplorerClient.js.map +1 -1
- package/package.json +7 -7
- package/src/config/RebalancerConfig.test.ts +2 -0
- package/src/config/RebalancerConfig.ts +9 -2
- package/src/config/types.ts +11 -0
- package/src/core/InventoryRebalancer.test.ts +37 -0
- package/src/core/InventoryRebalancer.ts +10 -0
- package/src/core/RebalancerOrchestrator.test.ts +10 -1
- package/src/core/RebalancerService.test.ts +6 -1
- package/src/e2e/harness/ForkIndexer.ts +1 -0
- package/src/e2e/harness/TestRebalancer.ts +3 -0
- package/src/factories/RebalancerContextFactory.ts +1 -0
- package/src/index.ts +2 -0
- package/src/tracking/ActionTracker.test.ts +321 -0
- package/src/tracking/ActionTracker.ts +61 -10
- package/src/tracking/IActionTracker.ts +3 -2
- package/src/tracking/types.ts +3 -1
- 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
|
|
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
|
|
528
|
-
*
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
123
|
-
*
|
|
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[]>;
|
package/src/tracking/types.ts
CHANGED
|
@@ -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
|
|