@hyperlane-xyz/rebalancer 3.2.0 → 25.5.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/bridges/LiFiBridge.d.ts +12 -2
- package/dist/bridges/LiFiBridge.d.ts.map +1 -1
- package/dist/bridges/LiFiBridge.js +36 -1
- package/dist/bridges/LiFiBridge.js.map +1 -1
- package/dist/bridges/LiFiBridge.test.d.ts +2 -0
- package/dist/bridges/LiFiBridge.test.d.ts.map +1 -0
- package/dist/bridges/LiFiBridge.test.js +412 -0
- package/dist/bridges/LiFiBridge.test.js.map +1 -0
- package/dist/config/types.d.ts +1 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +1 -0
- package/dist/config/types.js.map +1 -1
- package/dist/e2e/harness/MockExternalBridge.d.ts.map +1 -1
- package/dist/e2e/harness/MockExternalBridge.js +20 -0
- package/dist/e2e/harness/MockExternalBridge.js.map +1 -1
- package/dist/interfaces/IExternalBridge.d.ts +3 -2
- package/dist/interfaces/IExternalBridge.d.ts.map +1 -1
- package/dist/service.js +0 -0
- package/dist/test/lifiMocks.d.ts +1 -0
- package/dist/test/lifiMocks.d.ts.map +1 -1
- package/dist/test/lifiMocks.js +36 -6
- package/dist/test/lifiMocks.js.map +1 -1
- package/dist/tracking/ActionTracker.d.ts.map +1 -1
- package/dist/tracking/ActionTracker.js +44 -4
- package/dist/tracking/ActionTracker.js.map +1 -1
- package/dist/tracking/ActionTracker.test.js +284 -1
- package/dist/tracking/ActionTracker.test.js.map +1 -1
- package/dist/tracking/types.d.ts +3 -0
- package/dist/tracking/types.d.ts.map +1 -1
- package/package.json +7 -10
- package/src/bridges/LiFiBridge.test.ts +510 -0
- package/src/bridges/LiFiBridge.ts +84 -48
- package/src/config/types.ts +2 -0
- package/src/e2e/harness/MockExternalBridge.ts +32 -0
- package/src/interfaces/IExternalBridge.ts +3 -2
- package/src/test/lifiMocks.ts +43 -6
- package/src/tracking/ActionTracker.test.ts +336 -1
- package/src/tracking/ActionTracker.ts +53 -4
- package/src/tracking/types.ts +3 -0
|
@@ -5,7 +5,11 @@ import Sinon from 'sinon';
|
|
|
5
5
|
|
|
6
6
|
import { EthJsonRpcBlockParameterTag } from '@hyperlane-xyz/sdk';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_INTENT_TTL_MS,
|
|
10
|
+
DEFAULT_MOVEMENT_STALENESS_MS,
|
|
11
|
+
ExternalBridgeType,
|
|
12
|
+
} from '../config/types.js';
|
|
9
13
|
import type { ExplorerMessage } from '../utils/ExplorerClient.js';
|
|
10
14
|
|
|
11
15
|
import { ActionTracker, type ActionTrackerConfig } from './ActionTracker.js';
|
|
@@ -561,6 +565,115 @@ describe('ActionTracker', () => {
|
|
|
561
565
|
});
|
|
562
566
|
});
|
|
563
567
|
|
|
568
|
+
describe('syncInventoryMovementActions', () => {
|
|
569
|
+
it('stores pending status on in-progress movement', async () => {
|
|
570
|
+
await rebalanceActionStore.save({
|
|
571
|
+
id: 'action-pending',
|
|
572
|
+
type: 'inventory_movement',
|
|
573
|
+
status: 'in_progress',
|
|
574
|
+
intentId: 'intent-1',
|
|
575
|
+
origin: 1,
|
|
576
|
+
destination: 2,
|
|
577
|
+
amount: 100n,
|
|
578
|
+
txHash: '0xtx-pending',
|
|
579
|
+
externalBridgeId: ExternalBridgeType.LiFi,
|
|
580
|
+
createdAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
581
|
+
updatedAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const getStatus = Sinon.stub().resolves({ status: 'pending' });
|
|
585
|
+
await tracker.syncInventoryMovementActions({
|
|
586
|
+
lifi: { getStatus } as any,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const action = await rebalanceActionStore.get('action-pending');
|
|
590
|
+
expect(action?.status).to.equal('in_progress');
|
|
591
|
+
expect(action?.lastBridgeStatus).to.equal('pending');
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('stores not_found status on in-progress movement', async () => {
|
|
595
|
+
await rebalanceActionStore.save({
|
|
596
|
+
id: 'action-not-found',
|
|
597
|
+
type: 'inventory_movement',
|
|
598
|
+
status: 'in_progress',
|
|
599
|
+
intentId: 'intent-1',
|
|
600
|
+
origin: 1,
|
|
601
|
+
destination: 2,
|
|
602
|
+
amount: 100n,
|
|
603
|
+
txHash: '0xtx-not-found',
|
|
604
|
+
externalBridgeId: ExternalBridgeType.LiFi,
|
|
605
|
+
createdAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
606
|
+
updatedAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const getStatus = Sinon.stub().resolves({ status: 'not_found' });
|
|
610
|
+
await tracker.syncInventoryMovementActions({
|
|
611
|
+
lifi: { getStatus } as any,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const action = await rebalanceActionStore.get('action-not-found');
|
|
615
|
+
expect(action?.status).to.equal('in_progress');
|
|
616
|
+
expect(action?.lastBridgeStatus).to.equal('not_found');
|
|
617
|
+
expect(action?.nonPendingSince).to.be.a('number');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('clears nonPendingSince when status returns to pending', async () => {
|
|
621
|
+
await rebalanceActionStore.save({
|
|
622
|
+
id: 'action-back-to-pending',
|
|
623
|
+
type: 'inventory_movement',
|
|
624
|
+
status: 'in_progress',
|
|
625
|
+
intentId: 'intent-1',
|
|
626
|
+
origin: 1,
|
|
627
|
+
destination: 2,
|
|
628
|
+
amount: 100n,
|
|
629
|
+
txHash: '0xtx-back-pending',
|
|
630
|
+
externalBridgeId: ExternalBridgeType.LiFi,
|
|
631
|
+
lastBridgeStatus: 'not_found',
|
|
632
|
+
nonPendingSince: Date.now() - 60_000,
|
|
633
|
+
createdAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
634
|
+
updatedAt: Date.now(),
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const getStatus = Sinon.stub().resolves({ status: 'pending' });
|
|
638
|
+
await tracker.syncInventoryMovementActions({
|
|
639
|
+
lifi: { getStatus } as any,
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const action = await rebalanceActionStore.get('action-back-to-pending');
|
|
643
|
+
expect(action?.lastBridgeStatus).to.equal('pending');
|
|
644
|
+
expect(action?.nonPendingSince).to.be.undefined;
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('preserves existing nonPendingSince on repeated not_found polls', async () => {
|
|
648
|
+
const originalNonPendingSince = Date.now() - 120_000;
|
|
649
|
+
await rebalanceActionStore.save({
|
|
650
|
+
id: 'action-repeated-not-found',
|
|
651
|
+
type: 'inventory_movement',
|
|
652
|
+
status: 'in_progress',
|
|
653
|
+
intentId: 'intent-1',
|
|
654
|
+
origin: 1,
|
|
655
|
+
destination: 2,
|
|
656
|
+
amount: 100n,
|
|
657
|
+
txHash: '0xtx-repeated',
|
|
658
|
+
externalBridgeId: ExternalBridgeType.LiFi,
|
|
659
|
+
lastBridgeStatus: 'not_found',
|
|
660
|
+
nonPendingSince: originalNonPendingSince,
|
|
661
|
+
createdAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
662
|
+
updatedAt: Date.now(),
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const getStatus = Sinon.stub().resolves({ status: 'not_found' });
|
|
666
|
+
await tracker.syncInventoryMovementActions({
|
|
667
|
+
lifi: { getStatus } as any,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const action = await rebalanceActionStore.get(
|
|
671
|
+
'action-repeated-not-found',
|
|
672
|
+
);
|
|
673
|
+
expect(action?.nonPendingSince).to.equal(originalNonPendingSince);
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
564
677
|
describe('getInProgressTransfers', () => {
|
|
565
678
|
it('should return only in_progress transfers', async () => {
|
|
566
679
|
await transferStore.save({
|
|
@@ -876,6 +989,228 @@ describe('ActionTracker', () => {
|
|
|
876
989
|
// remaining = 0n AND inflightAmount = 0n → should NOT be returned
|
|
877
990
|
expect(partialIntents).to.have.lengthOf(0);
|
|
878
991
|
});
|
|
992
|
+
|
|
993
|
+
it('skips intent with recent in-flight inventory_movement', async () => {
|
|
994
|
+
await rebalanceIntentStore.save({
|
|
995
|
+
id: 'intent-recent-movement',
|
|
996
|
+
status: 'in_progress',
|
|
997
|
+
origin: 1,
|
|
998
|
+
destination: 2,
|
|
999
|
+
amount: 1000000000000000000n,
|
|
1000
|
+
executionMethod: 'inventory',
|
|
1001
|
+
createdAt: Date.now(),
|
|
1002
|
+
updatedAt: Date.now(),
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
// Recent movement (created just now)
|
|
1006
|
+
await rebalanceActionStore.save({
|
|
1007
|
+
id: 'movement-recent',
|
|
1008
|
+
type: 'inventory_movement',
|
|
1009
|
+
status: 'in_progress',
|
|
1010
|
+
intentId: 'intent-recent-movement',
|
|
1011
|
+
origin: 1,
|
|
1012
|
+
destination: 2,
|
|
1013
|
+
amount: 1000000000000000000n,
|
|
1014
|
+
createdAt: Date.now(),
|
|
1015
|
+
updatedAt: Date.now(),
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
const partialIntents =
|
|
1019
|
+
await tracker.getPartiallyFulfilledInventoryIntents();
|
|
1020
|
+
expect(partialIntents).to.have.lengthOf(0);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it('fails stale movement and returns intent', async () => {
|
|
1024
|
+
await rebalanceIntentStore.save({
|
|
1025
|
+
id: 'intent-stale-movement',
|
|
1026
|
+
status: 'in_progress',
|
|
1027
|
+
origin: 1,
|
|
1028
|
+
destination: 2,
|
|
1029
|
+
amount: 1000000000000000000n,
|
|
1030
|
+
executionMethod: 'inventory',
|
|
1031
|
+
createdAt: Date.now(),
|
|
1032
|
+
updatedAt: Date.now(),
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
// Stale movement (non-pending for > 30 min)
|
|
1036
|
+
await rebalanceActionStore.save({
|
|
1037
|
+
id: 'movement-stale',
|
|
1038
|
+
type: 'inventory_movement',
|
|
1039
|
+
status: 'in_progress',
|
|
1040
|
+
lastBridgeStatus: 'not_found',
|
|
1041
|
+
nonPendingSince: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
1042
|
+
intentId: 'intent-stale-movement',
|
|
1043
|
+
origin: 1,
|
|
1044
|
+
destination: 2,
|
|
1045
|
+
amount: 1000000000000000000n,
|
|
1046
|
+
createdAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
1047
|
+
updatedAt: Date.now(),
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
const partialIntents =
|
|
1051
|
+
await tracker.getPartiallyFulfilledInventoryIntents();
|
|
1052
|
+
expect(partialIntents).to.have.lengthOf(1);
|
|
1053
|
+
expect(partialIntents[0].intent.id).to.equal('intent-stale-movement');
|
|
1054
|
+
|
|
1055
|
+
// Verify the stale movement was failed
|
|
1056
|
+
const failedAction = await rebalanceActionStore.get('movement-stale');
|
|
1057
|
+
expect(failedAction?.status).to.equal('failed');
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it('fails stale movement with undefined lastBridgeStatus (pre-deploy data)', async () => {
|
|
1061
|
+
await rebalanceIntentStore.save({
|
|
1062
|
+
id: 'intent-undefined-status',
|
|
1063
|
+
status: 'in_progress',
|
|
1064
|
+
origin: 1,
|
|
1065
|
+
destination: 2,
|
|
1066
|
+
amount: 1000000000000000000n,
|
|
1067
|
+
executionMethod: 'inventory',
|
|
1068
|
+
createdAt: Date.now(),
|
|
1069
|
+
updatedAt: Date.now(),
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
// Stale movement without lastBridgeStatus (simulates pre-deploy data)
|
|
1073
|
+
await rebalanceActionStore.save({
|
|
1074
|
+
id: 'movement-undefined-status',
|
|
1075
|
+
type: 'inventory_movement',
|
|
1076
|
+
status: 'in_progress',
|
|
1077
|
+
intentId: 'intent-undefined-status',
|
|
1078
|
+
origin: 1,
|
|
1079
|
+
destination: 2,
|
|
1080
|
+
amount: 1000000000000000000n,
|
|
1081
|
+
createdAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
1082
|
+
updatedAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
const partialIntents =
|
|
1086
|
+
await tracker.getPartiallyFulfilledInventoryIntents();
|
|
1087
|
+
expect(partialIntents).to.have.lengthOf(1);
|
|
1088
|
+
|
|
1089
|
+
const action = await rebalanceActionStore.get(
|
|
1090
|
+
'movement-undefined-status',
|
|
1091
|
+
);
|
|
1092
|
+
expect(action?.status).to.equal('failed');
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it('does not fail long-running pending movement', async () => {
|
|
1096
|
+
await rebalanceIntentStore.save({
|
|
1097
|
+
id: 'intent-pending-movement',
|
|
1098
|
+
status: 'in_progress',
|
|
1099
|
+
origin: 1,
|
|
1100
|
+
destination: 2,
|
|
1101
|
+
amount: 1000000000000000000n,
|
|
1102
|
+
executionMethod: 'inventory',
|
|
1103
|
+
createdAt: Date.now(),
|
|
1104
|
+
updatedAt: Date.now(),
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
await rebalanceActionStore.save({
|
|
1108
|
+
id: 'movement-pending-old',
|
|
1109
|
+
type: 'inventory_movement',
|
|
1110
|
+
status: 'in_progress',
|
|
1111
|
+
lastBridgeStatus: 'pending',
|
|
1112
|
+
intentId: 'intent-pending-movement',
|
|
1113
|
+
origin: 1,
|
|
1114
|
+
destination: 2,
|
|
1115
|
+
amount: 1000000000000000000n,
|
|
1116
|
+
createdAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
1117
|
+
updatedAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
const partialIntents =
|
|
1121
|
+
await tracker.getPartiallyFulfilledInventoryIntents();
|
|
1122
|
+
expect(partialIntents).to.have.lengthOf(0);
|
|
1123
|
+
|
|
1124
|
+
const action = await rebalanceActionStore.get('movement-pending-old');
|
|
1125
|
+
expect(action?.status).to.equal('in_progress');
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it('does not fail old movement that recently became non-pending', async () => {
|
|
1129
|
+
await rebalanceIntentStore.save({
|
|
1130
|
+
id: 'intent-recent-not-found',
|
|
1131
|
+
status: 'in_progress',
|
|
1132
|
+
origin: 1,
|
|
1133
|
+
destination: 2,
|
|
1134
|
+
amount: 1000000000000000000n,
|
|
1135
|
+
executionMethod: 'inventory',
|
|
1136
|
+
createdAt: Date.now(),
|
|
1137
|
+
updatedAt: Date.now(),
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
// Old by createdAt but only recently transitioned to not_found
|
|
1141
|
+
await rebalanceActionStore.save({
|
|
1142
|
+
id: 'movement-recent-not-found',
|
|
1143
|
+
type: 'inventory_movement',
|
|
1144
|
+
status: 'in_progress',
|
|
1145
|
+
lastBridgeStatus: 'not_found',
|
|
1146
|
+
nonPendingSince: Date.now() - 60_000, // only 1 min ago
|
|
1147
|
+
intentId: 'intent-recent-not-found',
|
|
1148
|
+
origin: 1,
|
|
1149
|
+
destination: 2,
|
|
1150
|
+
amount: 1000000000000000000n,
|
|
1151
|
+
createdAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
1152
|
+
updatedAt: Date.now(),
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
const partialIntents =
|
|
1156
|
+
await tracker.getPartiallyFulfilledInventoryIntents();
|
|
1157
|
+
expect(partialIntents).to.have.lengthOf(0);
|
|
1158
|
+
|
|
1159
|
+
const action = await rebalanceActionStore.get(
|
|
1160
|
+
'movement-recent-not-found',
|
|
1161
|
+
);
|
|
1162
|
+
expect(action?.status).to.equal('in_progress');
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it('handles mix of recent and stale movements', async () => {
|
|
1166
|
+
await rebalanceIntentStore.save({
|
|
1167
|
+
id: 'intent-mixed-movements',
|
|
1168
|
+
status: 'in_progress',
|
|
1169
|
+
origin: 1,
|
|
1170
|
+
destination: 2,
|
|
1171
|
+
amount: 1000000000000000000n,
|
|
1172
|
+
executionMethod: 'inventory',
|
|
1173
|
+
createdAt: Date.now(),
|
|
1174
|
+
updatedAt: Date.now(),
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
// One stale movement (non-pending for > 30 min)
|
|
1178
|
+
await rebalanceActionStore.save({
|
|
1179
|
+
id: 'movement-old',
|
|
1180
|
+
type: 'inventory_movement',
|
|
1181
|
+
status: 'in_progress',
|
|
1182
|
+
lastBridgeStatus: 'not_found',
|
|
1183
|
+
nonPendingSince: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
1184
|
+
intentId: 'intent-mixed-movements',
|
|
1185
|
+
origin: 1,
|
|
1186
|
+
destination: 2,
|
|
1187
|
+
amount: 500000000000000000n,
|
|
1188
|
+
createdAt: Date.now() - DEFAULT_MOVEMENT_STALENESS_MS - 1,
|
|
1189
|
+
updatedAt: Date.now(),
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
// One recent movement
|
|
1193
|
+
await rebalanceActionStore.save({
|
|
1194
|
+
id: 'movement-new',
|
|
1195
|
+
type: 'inventory_movement',
|
|
1196
|
+
status: 'in_progress',
|
|
1197
|
+
lastBridgeStatus: 'pending',
|
|
1198
|
+
intentId: 'intent-mixed-movements',
|
|
1199
|
+
origin: 1,
|
|
1200
|
+
destination: 2,
|
|
1201
|
+
amount: 500000000000000000n,
|
|
1202
|
+
createdAt: Date.now(),
|
|
1203
|
+
updatedAt: Date.now(),
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
// Should skip because there's still a recent movement
|
|
1207
|
+
const partialIntents =
|
|
1208
|
+
await tracker.getPartiallyFulfilledInventoryIntents();
|
|
1209
|
+
expect(partialIntents).to.have.lengthOf(0);
|
|
1210
|
+
|
|
1211
|
+
const staleAction = await rebalanceActionStore.get('movement-old');
|
|
1212
|
+
expect(staleAction?.status).to.equal('in_progress');
|
|
1213
|
+
});
|
|
879
1214
|
});
|
|
880
1215
|
|
|
881
1216
|
describe('createRebalanceIntent', () => {
|
|
@@ -5,6 +5,7 @@ import type { MultiProtocolCore } from '@hyperlane-xyz/sdk';
|
|
|
5
5
|
import type { Address, Domain } from '@hyperlane-xyz/utils';
|
|
6
6
|
import { assert, parseWarpRouteMessage } from '@hyperlane-xyz/utils';
|
|
7
7
|
|
|
8
|
+
import { DEFAULT_MOVEMENT_STALENESS_MS } from '../config/types.js';
|
|
8
9
|
import type { ExternalBridgeRegistry } from '../interfaces/IExternalBridge.js';
|
|
9
10
|
import type { ConfirmedBlockTags } from '../interfaces/IMonitor.js';
|
|
10
11
|
import type {
|
|
@@ -589,12 +590,29 @@ export class ActionTracker implements IActionTracker {
|
|
|
589
590
|
const actions = await this.getActionsForIntent(intent.id);
|
|
590
591
|
|
|
591
592
|
// Check for in-flight inventory_movement actions
|
|
592
|
-
// Skip intents
|
|
593
|
-
|
|
593
|
+
// Skip intents with active bridge movement(s). Movements still `pending` on
|
|
594
|
+
// the bridge are kept alive regardless of age. Non-pending movements are only
|
|
595
|
+
// failed after they've been in a non-pending state for the staleness window
|
|
596
|
+
// (nonPendingSince), preventing premature failure from transient not_found polls.
|
|
597
|
+
// `undefined` status (pre-deploy data) falls back to createdAt for staleness.
|
|
598
|
+
const inflightMovements = actions.filter(
|
|
594
599
|
(a) => a.status === 'in_progress' && a.type === 'inventory_movement',
|
|
595
600
|
);
|
|
601
|
+
const now = Date.now();
|
|
602
|
+
const staleMovements = inflightMovements.filter((a) => {
|
|
603
|
+
if (a.lastBridgeStatus === 'pending') return false;
|
|
604
|
+
// Use nonPendingSince when available; fall back to createdAt for
|
|
605
|
+
// pre-deploy data that lacks the field.
|
|
606
|
+
const nonPendingStart = a.nonPendingSince ?? a.createdAt;
|
|
607
|
+
return now - nonPendingStart >= DEFAULT_MOVEMENT_STALENESS_MS;
|
|
608
|
+
});
|
|
609
|
+
const staleMovementIds = new Set(staleMovements.map((a) => a.id));
|
|
610
|
+
|
|
611
|
+
const hasBlockingInflightMovement = inflightMovements.some(
|
|
612
|
+
(a) => !staleMovementIds.has(a.id),
|
|
613
|
+
);
|
|
596
614
|
|
|
597
|
-
if (
|
|
615
|
+
if (hasBlockingInflightMovement) {
|
|
598
616
|
this.logger.debug(
|
|
599
617
|
{ intentId: intent.id },
|
|
600
618
|
'Skipping partial intent - has in-flight inventory movement',
|
|
@@ -602,6 +620,22 @@ export class ActionTracker implements IActionTracker {
|
|
|
602
620
|
continue;
|
|
603
621
|
}
|
|
604
622
|
|
|
623
|
+
// Fail stale movements so the intent can proceed
|
|
624
|
+
for (const movement of staleMovements) {
|
|
625
|
+
await this.failRebalanceAction(movement.id);
|
|
626
|
+
this.logger.warn(
|
|
627
|
+
{
|
|
628
|
+
actionId: movement.id,
|
|
629
|
+
age: now - movement.createdAt,
|
|
630
|
+
nonPendingDuration:
|
|
631
|
+
now - (movement.nonPendingSince ?? movement.createdAt),
|
|
632
|
+
intentId: intent.id,
|
|
633
|
+
lastBridgeStatus: movement.lastBridgeStatus,
|
|
634
|
+
},
|
|
635
|
+
'Failing stale inventory movement to unblock intent',
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
605
639
|
// Compute amounts from action states
|
|
606
640
|
const completedAmount = actions
|
|
607
641
|
.filter(
|
|
@@ -716,6 +750,10 @@ export class ActionTracker implements IActionTracker {
|
|
|
716
750
|
'Inventory movement failed',
|
|
717
751
|
);
|
|
718
752
|
} else if (status.status === 'pending') {
|
|
753
|
+
await this.rebalanceActionStore.update(action.id, {
|
|
754
|
+
lastBridgeStatus: 'pending',
|
|
755
|
+
nonPendingSince: undefined,
|
|
756
|
+
});
|
|
719
757
|
this.logger.debug(
|
|
720
758
|
{
|
|
721
759
|
actionId: action.id,
|
|
@@ -724,8 +762,19 @@ export class ActionTracker implements IActionTracker {
|
|
|
724
762
|
},
|
|
725
763
|
'Inventory movement still pending',
|
|
726
764
|
);
|
|
765
|
+
} else if (status.status === 'not_found') {
|
|
766
|
+
await this.rebalanceActionStore.update(action.id, {
|
|
767
|
+
lastBridgeStatus: 'not_found',
|
|
768
|
+
nonPendingSince: action.nonPendingSince ?? Date.now(),
|
|
769
|
+
});
|
|
770
|
+
this.logger.debug(
|
|
771
|
+
{
|
|
772
|
+
actionId: action.id,
|
|
773
|
+
txHash: action.txHash,
|
|
774
|
+
},
|
|
775
|
+
'Inventory movement not found',
|
|
776
|
+
);
|
|
727
777
|
}
|
|
728
|
-
// status === 'not_found' - wait for next cycle
|
|
729
778
|
} catch (error) {
|
|
730
779
|
this.logger.debug(
|
|
731
780
|
{
|
package/src/tracking/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Address, Domain } from '@hyperlane-xyz/utils';
|
|
2
2
|
|
|
3
3
|
import type { ExternalBridgeType } from '../config/types.js';
|
|
4
|
+
import type { BridgeTransferStatus } from '../interfaces/IExternalBridge.js';
|
|
4
5
|
|
|
5
6
|
import type { IStore } from './store/IStore.js';
|
|
6
7
|
|
|
@@ -84,6 +85,8 @@ export interface RebalanceAction extends TrackedActionBase {
|
|
|
84
85
|
// Fields for inventory_movement (external bridge)
|
|
85
86
|
externalBridgeTransferId?: string; // External bridge transfer ID (e.g., LiFi transfer ID)
|
|
86
87
|
externalBridgeId?: ExternalBridgeType; // External bridge identifier (e.g., 'lifi')
|
|
88
|
+
lastBridgeStatus?: BridgeTransferStatus['status']; // Last observed external bridge status
|
|
89
|
+
nonPendingSince?: number; // Timestamp when bridge status first became non-pending
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
// === Type Aliases for Stores ===
|