@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.
Files changed (39) hide show
  1. package/dist/bridges/LiFiBridge.d.ts +12 -2
  2. package/dist/bridges/LiFiBridge.d.ts.map +1 -1
  3. package/dist/bridges/LiFiBridge.js +36 -1
  4. package/dist/bridges/LiFiBridge.js.map +1 -1
  5. package/dist/bridges/LiFiBridge.test.d.ts +2 -0
  6. package/dist/bridges/LiFiBridge.test.d.ts.map +1 -0
  7. package/dist/bridges/LiFiBridge.test.js +412 -0
  8. package/dist/bridges/LiFiBridge.test.js.map +1 -0
  9. package/dist/config/types.d.ts +1 -0
  10. package/dist/config/types.d.ts.map +1 -1
  11. package/dist/config/types.js +1 -0
  12. package/dist/config/types.js.map +1 -1
  13. package/dist/e2e/harness/MockExternalBridge.d.ts.map +1 -1
  14. package/dist/e2e/harness/MockExternalBridge.js +20 -0
  15. package/dist/e2e/harness/MockExternalBridge.js.map +1 -1
  16. package/dist/interfaces/IExternalBridge.d.ts +3 -2
  17. package/dist/interfaces/IExternalBridge.d.ts.map +1 -1
  18. package/dist/service.js +0 -0
  19. package/dist/test/lifiMocks.d.ts +1 -0
  20. package/dist/test/lifiMocks.d.ts.map +1 -1
  21. package/dist/test/lifiMocks.js +36 -6
  22. package/dist/test/lifiMocks.js.map +1 -1
  23. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  24. package/dist/tracking/ActionTracker.js +44 -4
  25. package/dist/tracking/ActionTracker.js.map +1 -1
  26. package/dist/tracking/ActionTracker.test.js +284 -1
  27. package/dist/tracking/ActionTracker.test.js.map +1 -1
  28. package/dist/tracking/types.d.ts +3 -0
  29. package/dist/tracking/types.d.ts.map +1 -1
  30. package/package.json +7 -10
  31. package/src/bridges/LiFiBridge.test.ts +510 -0
  32. package/src/bridges/LiFiBridge.ts +84 -48
  33. package/src/config/types.ts +2 -0
  34. package/src/e2e/harness/MockExternalBridge.ts +32 -0
  35. package/src/interfaces/IExternalBridge.ts +3 -2
  36. package/src/test/lifiMocks.ts +43 -6
  37. package/src/tracking/ActionTracker.test.ts +336 -1
  38. package/src/tracking/ActionTracker.ts +53 -4
  39. 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 { DEFAULT_INTENT_TTL_MS } from '../config/types.js';
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 that have a bridge in progress - wait for it to complete
593
- const hasInflightMovement = actions.some(
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 (hasInflightMovement) {
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
  {
@@ -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 ===