@hyperlane-xyz/rebalancer 27.2.12 → 27.2.14

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 (97) hide show
  1. package/dist/bridges/LiFiBridge.js +1 -1
  2. package/dist/bridges/LiFiBridge.js.map +1 -1
  3. package/dist/bridges/LiFiBridge.test.js +37 -0
  4. package/dist/bridges/LiFiBridge.test.js.map +1 -1
  5. package/dist/core/InventoryRebalancer.d.ts +13 -19
  6. package/dist/core/InventoryRebalancer.d.ts.map +1 -1
  7. package/dist/core/InventoryRebalancer.js +400 -274
  8. package/dist/core/InventoryRebalancer.js.map +1 -1
  9. package/dist/core/InventoryRebalancer.test.js +706 -24
  10. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  11. package/dist/core/Rebalancer.d.ts.map +1 -1
  12. package/dist/core/Rebalancer.js +12 -6
  13. package/dist/core/Rebalancer.js.map +1 -1
  14. package/dist/core/Rebalancer.test.js +51 -0
  15. package/dist/core/Rebalancer.test.js.map +1 -1
  16. package/dist/core/RebalancerOrchestrator.test.js +0 -1
  17. package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
  18. package/dist/core/RebalancerService.d.ts +2 -3
  19. package/dist/core/RebalancerService.d.ts.map +1 -1
  20. package/dist/core/RebalancerService.js +3 -2
  21. package/dist/core/RebalancerService.js.map +1 -1
  22. package/dist/core/RebalancerService.test.js +24 -0
  23. package/dist/core/RebalancerService.test.js.map +1 -1
  24. package/dist/e2e/harness/TestHelpers.js +1 -2
  25. package/dist/e2e/harness/TestHelpers.js.map +1 -1
  26. package/dist/factories/RebalancerContextFactory.d.ts +4 -5
  27. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  28. package/dist/factories/RebalancerContextFactory.js +12 -7
  29. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  30. package/dist/factories/RebalancerContextFactory.test.js +99 -2
  31. package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
  32. package/dist/interfaces/IRebalancer.d.ts +4 -2
  33. package/dist/interfaces/IRebalancer.d.ts.map +1 -1
  34. package/dist/metrics/scripts/metrics.d.ts +1 -1
  35. package/dist/monitor/Monitor.d.ts.map +1 -1
  36. package/dist/monitor/Monitor.js +14 -6
  37. package/dist/monitor/Monitor.js.map +1 -1
  38. package/dist/strategy/BaseStrategy.d.ts.map +1 -1
  39. package/dist/strategy/BaseStrategy.js +13 -11
  40. package/dist/strategy/BaseStrategy.js.map +1 -1
  41. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
  42. package/dist/strategy/CollateralDeficitStrategy.js +2 -2
  43. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
  44. package/dist/strategy/MinAmountStrategy.d.ts +1 -0
  45. package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
  46. package/dist/strategy/MinAmountStrategy.js +12 -8
  47. package/dist/strategy/MinAmountStrategy.js.map +1 -1
  48. package/dist/strategy/MinAmountStrategy.test.js +189 -2
  49. package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
  50. package/dist/test/helpers.d.ts +11 -3
  51. package/dist/test/helpers.d.ts.map +1 -1
  52. package/dist/test/helpers.js +9 -11
  53. package/dist/test/helpers.js.map +1 -1
  54. package/dist/test/lifiMocks.d.ts.map +1 -1
  55. package/dist/test/lifiMocks.js +5 -2
  56. package/dist/test/lifiMocks.js.map +1 -1
  57. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  58. package/dist/tracking/ActionTracker.js +2 -1
  59. package/dist/tracking/ActionTracker.js.map +1 -1
  60. package/dist/tracking/ActionTracker.test.js +39 -0
  61. package/dist/tracking/ActionTracker.test.js.map +1 -1
  62. package/dist/utils/balanceUtils.d.ts +7 -1
  63. package/dist/utils/balanceUtils.d.ts.map +1 -1
  64. package/dist/utils/balanceUtils.js +39 -1
  65. package/dist/utils/balanceUtils.js.map +1 -1
  66. package/dist/utils/balanceUtils.test.js +55 -1
  67. package/dist/utils/balanceUtils.test.js.map +1 -1
  68. package/dist/utils/blockTag.d.ts +3 -3
  69. package/dist/utils/blockTag.d.ts.map +1 -1
  70. package/dist/utils/blockTag.js +1 -1
  71. package/dist/utils/blockTag.js.map +1 -1
  72. package/package.json +7 -7
  73. package/src/bridges/LiFiBridge.test.ts +43 -0
  74. package/src/bridges/LiFiBridge.ts +1 -1
  75. package/src/core/InventoryRebalancer.test.ts +932 -38
  76. package/src/core/InventoryRebalancer.ts +579 -361
  77. package/src/core/Rebalancer.test.ts +84 -0
  78. package/src/core/Rebalancer.ts +22 -6
  79. package/src/core/RebalancerOrchestrator.test.ts +0 -1
  80. package/src/core/RebalancerService.test.ts +35 -0
  81. package/src/core/RebalancerService.ts +9 -5
  82. package/src/e2e/harness/TestHelpers.ts +3 -3
  83. package/src/factories/RebalancerContextFactory.test.ts +143 -6
  84. package/src/factories/RebalancerContextFactory.ts +29 -17
  85. package/src/interfaces/IRebalancer.ts +4 -1
  86. package/src/monitor/Monitor.ts +19 -6
  87. package/src/strategy/BaseStrategy.ts +18 -15
  88. package/src/strategy/CollateralDeficitStrategy.ts +4 -3
  89. package/src/strategy/MinAmountStrategy.test.ts +238 -2
  90. package/src/strategy/MinAmountStrategy.ts +29 -17
  91. package/src/test/helpers.ts +13 -12
  92. package/src/test/lifiMocks.ts +5 -2
  93. package/src/tracking/ActionTracker.test.ts +47 -0
  94. package/src/tracking/ActionTracker.ts +2 -1
  95. package/src/utils/balanceUtils.test.ts +87 -1
  96. package/src/utils/balanceUtils.ts +73 -2
  97. package/src/utils/blockTag.ts +9 -4
@@ -3,27 +3,19 @@ import type { Logger } from 'pino';
3
3
  import {
4
4
  type ChainName,
5
5
  HyperlaneCore,
6
- type InterchainGasQuote,
7
- type IToken,
8
6
  type MultiProtocolSignerSignerAccountInfo,
9
7
  type MultiProvider,
10
- Token,
11
8
  ProviderType,
12
9
  SealevelCoreAdapter,
13
10
  TOKEN_COLLATERALIZED_STANDARDS,
14
- TokenAmount,
11
+ type Token,
15
12
  type WarpTypedTransaction,
16
13
  type WarpCore,
17
14
  WarpTxCategory,
18
15
  getSignerForChain,
19
16
  type TypedTransactionReceipt,
20
17
  } from '@hyperlane-xyz/sdk';
21
- import {
22
- ProtocolType,
23
- assert,
24
- ensure0x,
25
- isZeroishAddress,
26
- } from '@hyperlane-xyz/utils';
18
+ import { ProtocolType, assert, ensure0x, fromWei } from '@hyperlane-xyz/utils';
27
19
 
28
20
  import type { ExternalBridgeType } from '../config/types.js';
29
21
  import type {
@@ -51,6 +43,11 @@ import {
51
43
  } from '../utils/tokenUtils.js';
52
44
  import { parseSolanaPrivateKey } from '../utils/solanaKeyParser.js';
53
45
  import { toProtocolTransaction } from '../utils/transactionUtils.js';
46
+ import {
47
+ alignLocalToCanonical,
48
+ denormalizeToLocal,
49
+ normalizeToCanonical,
50
+ } from '../utils/balanceUtils.js';
54
51
 
55
52
  /**
56
53
  * Buffer percentage to add when bridging inventory.
@@ -72,6 +69,87 @@ const GAS_COST_MULTIPLIER = 20n;
72
69
  */
73
70
  const MAX_GAS_PERCENT_THRESHOLD = 10n;
74
71
 
72
+ type BridgeCapacity = {
73
+ maxSourceInput: bigint;
74
+ maxTargetOutput: bigint;
75
+ };
76
+
77
+ type BridgeQuoteMode = 'forward' | 'reverse';
78
+
79
+ type InventoryMovementExecutionResult =
80
+ | {
81
+ success: true;
82
+ txHash: string;
83
+ inputRequired: bigint;
84
+ quotedOutput: bigint;
85
+ quotedOutputMin: bigint;
86
+ quoteModeUsed: BridgeQuoteMode;
87
+ }
88
+ | {
89
+ success: false;
90
+ error: string;
91
+ };
92
+
93
+ const RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES = [
94
+ 'balance may be insufficient',
95
+ 'transfer amount exceeds balance',
96
+ 'insufficient balance',
97
+ ];
98
+
99
+ function hasRecoverableMaxTransferErrorMessage(message: string): boolean {
100
+ const normalized = message.toLowerCase();
101
+ return (
102
+ normalized.includes('unpredictable_gas_limit') ||
103
+ RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES.some((pattern) =>
104
+ normalized.includes(pattern),
105
+ )
106
+ );
107
+ }
108
+
109
+ function isRecoverableMaxTransferProbeError(error: unknown): boolean {
110
+ const seen = new Set<unknown>();
111
+ const stack: unknown[] = [error];
112
+
113
+ while (stack.length > 0) {
114
+ const current = stack.pop();
115
+ if (current == null) continue;
116
+
117
+ if (typeof current === 'string') {
118
+ if (hasRecoverableMaxTransferErrorMessage(current)) return true;
119
+ continue;
120
+ }
121
+
122
+ if (typeof current !== 'object') continue;
123
+ if (seen.has(current)) continue;
124
+ seen.add(current);
125
+
126
+ const candidate = current as {
127
+ code?: unknown;
128
+ message?: unknown;
129
+ cause?: unknown;
130
+ error?: unknown;
131
+ };
132
+
133
+ if (
134
+ typeof candidate.code === 'string' &&
135
+ candidate.code.toUpperCase() === 'UNPREDICTABLE_GAS_LIMIT'
136
+ ) {
137
+ return true;
138
+ }
139
+
140
+ if (
141
+ typeof candidate.message === 'string' &&
142
+ hasRecoverableMaxTransferErrorMessage(candidate.message)
143
+ ) {
144
+ return true;
145
+ }
146
+
147
+ stack.push(candidate.cause, candidate.error);
148
+ }
149
+
150
+ return false;
151
+ }
152
+
75
153
  /**
76
154
  * Configuration for the InventoryRebalancer.
77
155
  */
@@ -288,6 +366,10 @@ export class InventoryRebalancer implements IInventoryRebalancer {
288
366
  return total;
289
367
  }
290
368
 
369
+ private formatLocalAmount(amount: bigint, token: Token): string {
370
+ return fromWei(amount.toString(), token.decimals);
371
+ }
372
+
291
373
  /**
292
374
  * Get the effective available inventory for a chain, accounting for
293
375
  * inventory already consumed during this execution cycle.
@@ -533,12 +615,18 @@ export class InventoryRebalancer implements IInventoryRebalancer {
533
615
  {
534
616
  strategyRoute: `${origin} (surplus) → ${destination} (deficit)`,
535
617
  executionRoute: `transferRemote FROM ${destination} TO ${origin}`,
536
- amount: amount.toString(),
618
+ canonicalAmount: amount.toString(),
537
619
  intentId: intent.id,
538
620
  },
539
621
  'Executing inventory route',
540
622
  );
541
623
 
624
+ const sourceToken = this.getTokenForChain(destination);
625
+ assert(sourceToken, `No token found for source chain: ${destination}`);
626
+ const requestedLocalAmount = denormalizeToLocal(amount, sourceToken);
627
+ const executionSender = this.getInventorySignerAddress(destination);
628
+ const executionRecipient = this.getInventorySignerAddress(origin);
629
+
542
630
  // Check available inventory on the DESTINATION (deficit) chain
543
631
  // We need inventory here because transferRemote is called FROM this chain
544
632
  const availableInventory = this.getEffectiveAvailableInventory(destination);
@@ -547,9 +635,15 @@ export class InventoryRebalancer implements IInventoryRebalancer {
547
635
  {
548
636
  checkingChain: destination,
549
637
  availableInventory: availableInventory.toString(),
550
- availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
551
- requiredAmount: amount.toString(),
552
- requiredAmountEth: (Number(amount) / 1e18).toFixed(6),
638
+ availableInventoryFormatted: this.formatLocalAmount(
639
+ availableInventory,
640
+ sourceToken,
641
+ ),
642
+ requiredAmount: requestedLocalAmount.toString(),
643
+ requiredAmountFormatted: this.formatLocalAmount(
644
+ requestedLocalAmount,
645
+ sourceToken,
646
+ ),
553
647
  },
554
648
  'Checking effective inventory on destination (deficit) chain',
555
649
  );
@@ -560,15 +654,72 @@ export class InventoryRebalancer implements IInventoryRebalancer {
560
654
  destination, // FROM chain (where transferRemote is called)
561
655
  origin, // TO chain (where Hyperlane message goes)
562
656
  availableInventory,
563
- amount,
657
+ requestedLocalAmount,
564
658
  this.multiProvider,
565
659
  this.warpCore.multiProvider,
566
660
  this.getTokenForChain.bind(this),
567
- this.getInventorySignerAddress(destination),
661
+ executionSender,
568
662
  isNativeTokenStandard,
569
663
  this.logger,
570
664
  );
571
- const { maxTransferable, minViableTransfer } = costs;
665
+ const { minViableTransfer } = costs;
666
+ let maxTransferable = costs.maxTransferable;
667
+
668
+ if (!isNativeTokenStandard(sourceToken.standard)) {
669
+ if (availableInventory === 0n) {
670
+ maxTransferable = 0n;
671
+ this.logger.debug(
672
+ {
673
+ fromChain: destination,
674
+ toChain: origin,
675
+ requestedAmount: requestedLocalAmount.toString(),
676
+ },
677
+ 'Skipping fee-aware max transferable probe because destination inventory is zero',
678
+ );
679
+ } else {
680
+ try {
681
+ const feeAwareMaxTransfer = await this.warpCore.getMaxTransferAmount({
682
+ balance: sourceToken.amount(availableInventory),
683
+ destination: origin,
684
+ sender: executionSender,
685
+ recipient: executionRecipient,
686
+ });
687
+
688
+ maxTransferable =
689
+ feeAwareMaxTransfer.amount < requestedLocalAmount
690
+ ? feeAwareMaxTransfer.amount
691
+ : requestedLocalAmount;
692
+
693
+ this.logger.debug(
694
+ {
695
+ fromChain: destination,
696
+ toChain: origin,
697
+ availableInventory: availableInventory.toString(),
698
+ requestedAmount: requestedLocalAmount.toString(),
699
+ feeAwareMaxTransferable: maxTransferable.toString(),
700
+ },
701
+ 'Calculated fee-aware max transferable amount for non-native route',
702
+ );
703
+ } catch (error) {
704
+ if (!isRecoverableMaxTransferProbeError(error)) {
705
+ throw error;
706
+ }
707
+
708
+ maxTransferable = 0n;
709
+ this.logger.warn(
710
+ {
711
+ fromChain: destination,
712
+ toChain: origin,
713
+ availableInventory: availableInventory.toString(),
714
+ requestedAmount: requestedLocalAmount.toString(),
715
+ error: error instanceof Error ? error.message : String(error),
716
+ intentId: intent.id,
717
+ },
718
+ 'Fee-aware max transferable probe failed due to insufficient balance, falling back to external bridge',
719
+ );
720
+ }
721
+ }
722
+ }
572
723
 
573
724
  // Calculate total inventory across all chains
574
725
  // Note: consumedInventory tracking is handled separately within this cycle
@@ -578,24 +729,36 @@ export class InventoryRebalancer implements IInventoryRebalancer {
578
729
  {
579
730
  fromChain: destination,
580
731
  toChain: origin,
581
- availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
582
- requestedAmountEth: (Number(amount) / 1e18).toFixed(6),
583
- maxTransferableEth: (Number(maxTransferable) / 1e18).toFixed(6),
584
- minViableTransferEth: (Number(minViableTransfer) / 1e18).toFixed(6),
585
- totalInventoryEth: (Number(totalInventory) / 1e18).toFixed(6),
586
- canFullyFulfill: maxTransferable >= amount,
732
+ availableInventoryFormatted: this.formatLocalAmount(
733
+ availableInventory,
734
+ sourceToken,
735
+ ),
736
+ requestedAmountFormatted: this.formatLocalAmount(
737
+ requestedLocalAmount,
738
+ sourceToken,
739
+ ),
740
+ maxTransferableFormatted: this.formatLocalAmount(
741
+ maxTransferable,
742
+ sourceToken,
743
+ ),
744
+ minViableTransferFormatted: this.formatLocalAmount(
745
+ minViableTransfer,
746
+ sourceToken,
747
+ ),
748
+ canFullyFulfill: maxTransferable >= requestedLocalAmount,
587
749
  canPartialFulfill: maxTransferable >= minViableTransfer,
750
+ totalInventory: totalInventory.toString(),
588
751
  },
589
752
  'Calculated max transferable amount with cost-based threshold',
590
753
  );
591
754
 
592
755
  // Early exit: If remaining amount is below minViableTransfer, complete the intent
593
756
  // This prevents infinite loops when the remaining amount is too small to economically bridge
594
- if (amount < minViableTransfer) {
757
+ if (requestedLocalAmount < minViableTransfer) {
595
758
  this.logger.info(
596
759
  {
597
760
  intentId: intent.id,
598
- amount: amount.toString(),
761
+ amount: requestedLocalAmount.toString(),
599
762
  minViableTransfer: minViableTransfer.toString(),
600
763
  },
601
764
  'Remaining amount below minViableTransfer, completing intent with acceptable loss',
@@ -616,227 +779,291 @@ export class InventoryRebalancer implements IInventoryRebalancer {
616
779
  ...route,
617
780
  origin: destination, // transferRemote called FROM here
618
781
  destination: origin, // Hyperlane message goes TO here
782
+ amount: requestedLocalAmount,
619
783
  };
620
784
 
621
- if (maxTransferable >= amount) {
785
+ if (maxTransferable >= requestedLocalAmount) {
622
786
  // Sufficient inventory on destination - execute transferRemote directly
787
+ const fulfilledCanonicalAmount = normalizeToCanonical(
788
+ requestedLocalAmount,
789
+ sourceToken,
790
+ );
623
791
  const result = await this.executeTransferRemote(
624
792
  swappedRoute,
625
793
  intent,
626
- costs.gasQuote!,
794
+ fulfilledCanonicalAmount,
627
795
  );
628
796
  // Return original strategy route in result (not the swapped execution route)
629
797
  return { ...result, route };
630
798
  } else if (maxTransferable > 0n && maxTransferable >= minViableTransfer) {
631
799
  // Partial transfer: Transfer available inventory when economically viable
632
- const partialSwappedRoute: InventoryRoute = {
633
- ...swappedRoute,
634
- amount: maxTransferable,
635
- };
636
- const result = await this.executeTransferRemote(
637
- partialSwappedRoute,
638
- intent,
639
- costs.gasQuote!,
640
- );
641
-
642
- this.logger.info(
643
- {
644
- intentId: intent.id,
645
- partialAmount: maxTransferable.toString(),
646
- requestedAmount: amount.toString(),
647
- remainingAmount: (amount - maxTransferable).toString(),
648
- },
649
- 'Executed partial inventory deposit, remaining will be handled in future cycles',
650
- );
651
-
652
- // Return original strategy route in result (not the swapped execution route)
653
- return { ...result, route };
654
- } else {
655
- // Inventory below cost-based threshold - trigger ExternalBridge movement TO destination chain
656
- this.logger.info(
657
- {
658
- targetChain: destination,
659
- maxTransferable: maxTransferable.toString(),
660
- minViableTransfer: minViableTransfer.toString(),
661
- costMultiplier: MIN_VIABLE_COST_MULTIPLIER.toString(),
662
- intentId: intent.id,
663
- },
664
- 'Inventory below cost-based threshold on destination, triggering LiFi movement',
800
+ const alignedExecution = alignLocalToCanonical(
801
+ maxTransferable,
802
+ sourceToken,
665
803
  );
666
-
667
- // Get all available source chains with raw inventory
668
- const allSources = this.selectAllSourceChains(destination);
669
-
670
- if (allSources.length === 0) {
671
- this.logger.warn(
804
+ if (alignedExecution.messageAmount === 0n) {
805
+ this.logger.info(
672
806
  {
673
- origin,
674
- destination,
675
- amount: amount.toString(),
676
807
  intentId: intent.id,
808
+ maxTransferable: maxTransferable.toString(),
677
809
  },
678
- 'No inventory available on any monitored chain',
810
+ 'Skipping partial transferRemote because available local amount cannot produce canonical progress',
679
811
  );
680
-
681
- return {
682
- route,
683
- success: false,
684
- error: 'No inventory available on any monitored chain',
812
+ } else {
813
+ const partialSwappedRoute: InventoryRoute = {
814
+ ...swappedRoute,
815
+ amount: alignedExecution.localAmount,
685
816
  };
686
- }
687
-
688
- // NEW: Calculate max viable amount for each source chain
689
- // This uses the quote API to determine gas costs upfront
690
- const viableSources: Array<{ chain: ChainName; maxViable: bigint }> = [];
691
-
692
- for (const source of allSources) {
693
- const maxViable = await this.calculateMaxViableBridgeAmount(
694
- source.chain,
695
- destination,
696
- source.availableAmount,
697
- route.externalBridge,
817
+ const result = await this.executeTransferRemote(
818
+ partialSwappedRoute,
819
+ intent,
820
+ alignedExecution.messageAmount,
698
821
  );
699
822
 
700
- if (maxViable > 0n) {
701
- viableSources.push({ chain: source.chain, maxViable });
702
- }
703
- }
704
-
705
- // Sort by max viable descending (bridge from largest sources first)
706
- viableSources.sort((a, b) => (a.maxViable > b.maxViable ? -1 : 1));
707
-
708
- if (viableSources.length === 0) {
709
- this.logger.warn(
823
+ this.logger.info(
710
824
  {
711
- targetChain: destination,
712
- sourcesChecked: allSources.length,
713
825
  intentId: intent.id,
826
+ partialAmount: alignedExecution.localAmount.toString(),
827
+ partialAmountCanonical: alignedExecution.messageAmount.toString(),
828
+ requestedAmount: requestedLocalAmount.toString(),
829
+ requestedAmountCanonical: amount.toString(),
830
+ remainingAmountCanonical: (amount > alignedExecution.messageAmount
831
+ ? amount - alignedExecution.messageAmount
832
+ : 0n
833
+ ).toString(),
714
834
  },
715
- 'No viable bridge sources - all chains have insufficient inventory or high gas costs',
835
+ 'Executed partial inventory deposit, remaining will be handled in future cycles',
716
836
  );
717
837
 
718
- return {
719
- route,
720
- success: false,
721
- error: 'No viable bridge sources available',
722
- };
838
+ // Return original strategy route in result (not the swapped execution route)
839
+ return { ...result, route };
723
840
  }
841
+ }
842
+
843
+ // Inventory below cost-based threshold - trigger ExternalBridge movement TO destination chain
844
+ this.logger.info(
845
+ {
846
+ targetChain: destination,
847
+ maxTransferable: maxTransferable.toString(),
848
+ minViableTransfer: minViableTransfer.toString(),
849
+ costMultiplier: MIN_VIABLE_COST_MULTIPLIER.toString(),
850
+ intentId: intent.id,
851
+ },
852
+ 'Inventory below cost-based threshold on destination, triggering LiFi movement',
853
+ );
854
+
855
+ // Get all available source chains with raw inventory
856
+ const allSources = this.selectAllSourceChains(destination);
857
+
858
+ if (allSources.length === 0) {
859
+ this.logger.warn(
860
+ {
861
+ origin,
862
+ destination,
863
+ amount: requestedLocalAmount.toString(),
864
+ intentId: intent.id,
865
+ },
866
+ 'No inventory available on any monitored chain',
867
+ );
724
868
 
725
- // Create bridge plans using VIABLE amounts (gas already accounted for)
726
- const targetWithBuffer =
727
- ((amount + costs.totalCost) * (100n + BRIDGE_BUFFER_PERCENT)) / 100n;
728
- const bridgePlans: Array<{ chain: ChainName; amount: bigint }> = [];
729
- let totalPlanned = 0n;
869
+ return {
870
+ route,
871
+ success: false,
872
+ error: 'No inventory available on any monitored chain',
873
+ };
874
+ }
730
875
 
731
- for (const source of viableSources) {
732
- if (totalPlanned >= targetWithBuffer) break;
876
+ // Calculate source capacities in destination-local units.
877
+ const viableSources: Array<{
878
+ chain: ChainName;
879
+ maxSourceInput: bigint;
880
+ maxTargetOutput: bigint;
881
+ }> = [];
733
882
 
734
- const remaining = targetWithBuffer - totalPlanned;
735
- const amountFromSource =
736
- source.maxViable >= remaining ? remaining : source.maxViable; // Already gas-adjusted!
883
+ for (const source of allSources) {
884
+ const capacity = await this.calculateBridgeCapacity(
885
+ source.chain,
886
+ destination,
887
+ source.availableAmount,
888
+ route.externalBridge,
889
+ );
737
890
 
738
- bridgePlans.push({
739
- chain: source.chain,
740
- amount: amountFromSource,
741
- });
742
- totalPlanned += amountFromSource;
891
+ if (capacity.maxTargetOutput > 0n) {
892
+ viableSources.push({ chain: source.chain, ...capacity });
743
893
  }
894
+ }
744
895
 
745
- this.logger.info(
896
+ // Sort by destination output descending.
897
+ viableSources.sort((a, b) =>
898
+ a.maxTargetOutput > b.maxTargetOutput ? -1 : 1,
899
+ );
900
+
901
+ if (viableSources.length === 0) {
902
+ this.logger.warn(
746
903
  {
747
904
  targetChain: destination,
748
- viableSources: viableSources.map((s) => ({
749
- chain: s.chain,
750
- maxViable: s.maxViable.toString(),
751
- maxViableEth: (Number(s.maxViable) / 1e18).toFixed(6),
752
- })),
753
- bridgePlans: bridgePlans.map((p) => ({
754
- chain: p.chain,
755
- amount: p.amount.toString(),
756
- amountEth: (Number(p.amount) / 1e18).toFixed(6),
757
- })),
758
- totalPlanned: totalPlanned.toString(),
759
- targetWithBuffer: targetWithBuffer.toString(),
905
+ sourcesChecked: allSources.length,
760
906
  intentId: intent.id,
761
907
  },
762
- 'Created bridge plans using gas-adjusted viable amounts',
908
+ 'No viable bridge sources - all chains have insufficient inventory or high gas costs',
763
909
  );
764
910
 
765
- // Execute all bridges in parallel
766
- const bridgeResults = await Promise.allSettled(
767
- bridgePlans.map((plan) =>
768
- this.executeInventoryMovement(
769
- plan.chain,
770
- destination,
771
- plan.amount,
772
- intent,
773
- route.externalBridge,
774
- ),
911
+ return {
912
+ route,
913
+ success: false,
914
+ error: 'No viable bridge sources available',
915
+ };
916
+ }
917
+
918
+ // Create bridge plans using destination-local output amounts.
919
+ const shortfall =
920
+ requestedLocalAmount > availableInventory
921
+ ? requestedLocalAmount - availableInventory
922
+ : 0n;
923
+ const targetWithBuffer =
924
+ ((shortfall + costs.totalCost) * (100n + BRIDGE_BUFFER_PERCENT)) / 100n;
925
+ const bridgePlans: Array<{
926
+ chain: ChainName;
927
+ maxSourceInput: bigint;
928
+ targetOutput: bigint;
929
+ quoteMode: BridgeQuoteMode;
930
+ }> = [];
931
+ let totalPlanned = 0n;
932
+
933
+ for (const source of viableSources) {
934
+ if (totalPlanned >= targetWithBuffer) break;
935
+
936
+ const remaining = targetWithBuffer - totalPlanned;
937
+ const targetOutput =
938
+ source.maxTargetOutput >= remaining
939
+ ? remaining
940
+ : source.maxTargetOutput;
941
+ const quoteMode: BridgeQuoteMode =
942
+ source.maxTargetOutput > remaining ? 'reverse' : 'forward';
943
+
944
+ bridgePlans.push({
945
+ chain: source.chain,
946
+ maxSourceInput: source.maxSourceInput,
947
+ targetOutput,
948
+ quoteMode,
949
+ });
950
+ totalPlanned += targetOutput;
951
+ }
952
+
953
+ this.logger.info(
954
+ {
955
+ targetChain: destination,
956
+ viableSources: viableSources.map((s) => ({
957
+ chain: s.chain,
958
+ maxSourceInput: s.maxSourceInput.toString(),
959
+ maxTargetOutput: s.maxTargetOutput.toString(),
960
+ })),
961
+ bridgePlans: bridgePlans.map((p) => ({
962
+ chain: p.chain,
963
+ maxSourceInput: p.maxSourceInput.toString(),
964
+ targetOutput: p.targetOutput.toString(),
965
+ quoteMode: p.quoteMode,
966
+ })),
967
+ totalPlanned: totalPlanned.toString(),
968
+ shortfall: shortfall.toString(),
969
+ targetWithBuffer: targetWithBuffer.toString(),
970
+ intentId: intent.id,
971
+ },
972
+ 'Created bridge plans using gas-adjusted viable amounts',
973
+ );
974
+
975
+ // Execute all bridges in parallel
976
+ const bridgeResults = await Promise.allSettled(
977
+ bridgePlans.map((plan) =>
978
+ this.executeInventoryMovement(
979
+ plan.chain,
980
+ destination,
981
+ plan.targetOutput,
982
+ plan.maxSourceInput,
983
+ plan.quoteMode,
984
+ intent,
985
+ route.externalBridge,
775
986
  ),
776
- );
987
+ ),
988
+ );
777
989
 
778
- // Process results
779
- let successCount = 0;
780
- let totalBridged = 0n;
781
- const failedErrors: string[] = [];
990
+ // Process results
991
+ let successCount = 0;
992
+ let totalQuotedOutputMin = 0n;
993
+ const failedErrors: string[] = [];
782
994
 
783
- for (let i = 0; i < bridgeResults.length; i++) {
784
- const result = bridgeResults[i];
785
- const plan = bridgePlans[i];
995
+ for (let i = 0; i < bridgeResults.length; i++) {
996
+ const result = bridgeResults[i];
997
+ const plan = bridgePlans[i];
786
998
 
787
- if (result.status === 'fulfilled' && result.value.success) {
788
- successCount++;
789
- totalBridged += plan.amount;
790
- this.logger.info(
791
- {
792
- sourceChain: plan.chain,
793
- amount: plan.amount.toString(),
794
- txHash: result.value.txHash,
795
- },
796
- 'Inventory movement succeeded',
797
- );
798
- } else {
799
- const error =
800
- result.status === 'rejected'
801
- ? result.reason?.message
802
- : result.value.error;
803
- if (error) {
804
- failedErrors.push(`${plan.chain}: ${error}`);
999
+ if (result.status === 'fulfilled' && result.value.success) {
1000
+ successCount++;
1001
+ totalQuotedOutputMin += result.value.quotedOutputMin;
1002
+ this.logger.info(
1003
+ {
1004
+ sourceChain: plan.chain,
1005
+ plannedTargetOutput: plan.targetOutput.toString(),
1006
+ quotedOutput: result.value.quotedOutput.toString(),
1007
+ quotedOutputMin: result.value.quotedOutputMin.toString(),
1008
+ quoteModeUsed: result.value.quoteModeUsed,
1009
+ txHash: result.value.txHash,
1010
+ },
1011
+ 'Inventory movement succeeded',
1012
+ );
1013
+ } else {
1014
+ let error: string | undefined;
1015
+ if (result.status === 'rejected') {
1016
+ if (result.reason instanceof Error) {
1017
+ error = result.reason.message;
1018
+ } else if (typeof result.reason === 'string') {
1019
+ error = result.reason;
1020
+ } else {
1021
+ try {
1022
+ error = JSON.stringify(result.reason) ?? String(result.reason);
1023
+ } catch {
1024
+ error = String(result.reason);
1025
+ }
805
1026
  }
806
- this.logger.warn(
807
- {
808
- sourceChain: plan.chain,
809
- amount: plan.amount.toString(),
810
- error,
811
- },
812
- 'Inventory movement failed',
813
- );
1027
+ } else if (!result.value.success) {
1028
+ error = result.value.error;
1029
+ }
1030
+ if (error) {
1031
+ failedErrors.push(`${plan.chain}: ${error}`);
814
1032
  }
1033
+ this.logger.warn(
1034
+ {
1035
+ sourceChain: plan.chain,
1036
+ plannedTargetOutput: plan.targetOutput.toString(),
1037
+ error,
1038
+ },
1039
+ 'Inventory movement failed',
1040
+ );
815
1041
  }
1042
+ }
816
1043
 
817
- if (successCount === 0) {
818
- const errorDetails =
819
- failedErrors.length > 0 ? ` (${failedErrors.join('; ')})` : '';
820
- return {
821
- route,
822
- success: false,
823
- error: `All inventory movements failed${errorDetails}`,
824
- };
825
- }
1044
+ if (successCount === 0) {
1045
+ const errorDetails =
1046
+ failedErrors.length > 0 ? ` (${failedErrors.join('; ')})` : '';
1047
+ return {
1048
+ route,
1049
+ success: false,
1050
+ error: `All inventory movements failed${errorDetails}`,
1051
+ };
1052
+ }
826
1053
 
827
- this.logger.info(
828
- {
829
- targetChain: destination,
830
- successCount,
831
- totalBridged: totalBridged.toString(),
832
- targetAmount: amount.toString(),
833
- intentId: intent.id,
834
- },
835
- 'Parallel inventory movements completed, transferRemote will execute after bridges complete',
836
- );
1054
+ this.logger.info(
1055
+ {
1056
+ targetChain: destination,
1057
+ successCount,
1058
+ totalQuotedOutputMin: totalQuotedOutputMin.toString(),
1059
+ targetAmount: requestedLocalAmount.toString(),
1060
+ targetAmountCanonical: amount.toString(),
1061
+ intentId: intent.id,
1062
+ },
1063
+ 'Parallel inventory movements completed, transferRemote will execute after bridges complete',
1064
+ );
837
1065
 
838
- return { route, success: true };
839
- }
1066
+ return { route, success: true };
840
1067
  }
841
1068
 
842
1069
  /**
@@ -852,12 +1079,11 @@ export class InventoryRebalancer implements IInventoryRebalancer {
852
1079
  *
853
1080
  * @param route - The transfer route (swapped direction)
854
1081
  * @param intent - The rebalance intent being executed
855
- * @param gasQuote - Pre-calculated gas quote from calculateTransferCosts
856
1082
  */
857
1083
  private async executeTransferRemote(
858
1084
  route: InventoryRoute,
859
1085
  intent: RebalanceIntent,
860
- gasQuote: InterchainGasQuote,
1086
+ fulfilledCanonicalAmount: bigint,
861
1087
  ): Promise<InventoryExecutionResult> {
862
1088
  const { origin, destination, amount } = route;
863
1089
 
@@ -869,52 +1095,16 @@ export class InventoryRebalancer implements IInventoryRebalancer {
869
1095
  const destinationDomain = this.multiProvider.getDomainId(destination);
870
1096
 
871
1097
  this.logger.debug(
872
- {
873
- origin,
874
- destination,
875
- amount: amount.toString(),
876
- gasQuote: {
877
- igpQuote: gasQuote.igpQuote.amount.toString(),
878
- tokenFeeQuote: gasQuote.tokenFeeQuote?.amount?.toString() ?? 'none',
879
- },
880
- },
881
- 'Using pre-calculated gas quote for transferRemote',
1098
+ { origin, destination, amount: amount.toString() },
1099
+ 'Building transferRemote transactions for exact execution amount',
882
1100
  );
883
1101
 
884
- // Convert pre-calculated gas quote to TokenAmount for WarpCore
885
- const originChainMetadata = this.multiProvider.getChainMetadata(origin);
886
- const igpAddressOrDenom = gasQuote.igpQuote.addressOrDenom;
887
- const igpToken =
888
- !igpAddressOrDenom || isZeroishAddress(igpAddressOrDenom)
889
- ? Token.FromChainMetadataNativeToken(originChainMetadata)
890
- : this.warpCore.findToken(origin, igpAddressOrDenom);
891
- assert(igpToken, `IGP fee token ${igpAddressOrDenom} is unknown`);
892
- const interchainFee = new TokenAmount<IToken>(
893
- gasQuote.igpQuote.amount,
894
- igpToken,
895
- );
896
-
897
- let tokenFeeQuote: TokenAmount<IToken> | undefined;
898
- if (gasQuote.tokenFeeQuote?.amount) {
899
- const feeAddress = gasQuote.tokenFeeQuote.addressOrDenom;
900
- const feeToken =
901
- !feeAddress || isZeroishAddress(feeAddress)
902
- ? Token.FromChainMetadataNativeToken(originChainMetadata)
903
- : originToken;
904
- tokenFeeQuote = new TokenAmount<IToken>(
905
- gasQuote.tokenFeeQuote.amount,
906
- feeToken,
907
- );
908
- }
909
-
910
1102
  const originTokenAmount = originToken.amount(amount);
911
1103
  const transferTxs = await this.warpCore.getTransferRemoteTxs({
912
1104
  originTokenAmount,
913
1105
  destination,
914
1106
  sender: this.getInventorySignerAddress(origin),
915
1107
  recipient: this.getInventorySignerAddress(destination),
916
- interchainFee,
917
- tokenFeeQuote,
918
1108
  });
919
1109
  assert(
920
1110
  transferTxs.length > 0,
@@ -974,7 +1164,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
974
1164
  intentId: intent.id,
975
1165
  origin: this.multiProvider.getDomainId(origin),
976
1166
  destination: destinationDomain,
977
- amount,
1167
+ amount: fulfilledCanonicalAmount,
978
1168
  type: 'inventory_deposit',
979
1169
  txHash: transferTxHash,
980
1170
  messageId,
@@ -1129,40 +1319,30 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1129
1319
  }
1130
1320
 
1131
1321
  /**
1132
- * Calculate the maximum amount that can be bridged from a source chain.
1133
- * Uses LiFi quote to determine gas costs, applies 20x multiplier buffer.
1134
- * Returns 0 if gas exceeds 10% of inventory (not economically viable).
1322
+ * Calculate the bridge capacity from a source chain in destination-local units.
1323
+ * Uses LiFi quotes to conservatively estimate the destination output available
1324
+ * from the source chain's current local inventory.
1135
1325
  *
1136
- * This is the key method for the gas-aware planning approach:
1137
- * - Gets a quote for the full raw inventory to determine actual gas costs
1138
- * - Applies conservative 20x buffer (LiFi underestimates by ~14x historically)
1139
- * - Returns 0 if gas > 10% of inventory (not worth bridging)
1140
- * - Returns inventory - estimatedGas if viable
1141
- *
1142
- * @param sourceChain - Chain to bridge from
1143
- * @param targetChain - Chain to bridge to
1144
- * @param rawInventory - Raw available inventory on source chain
1145
- * @param externalBridgeType - External bridge type to use
1146
- * @returns Maximum viable bridge amount (0 if not viable)
1326
+ * For native-token sources, gas is reserved from the source inventory and the
1327
+ * output capacity is re-quoted from the remaining source input.
1147
1328
  */
1148
- private async calculateMaxViableBridgeAmount(
1329
+ private async calculateBridgeCapacity(
1149
1330
  sourceChain: ChainName,
1150
1331
  targetChain: ChainName,
1151
1332
  rawInventory: bigint,
1152
1333
  externalBridgeType: ExternalBridgeType,
1153
- ): Promise<bigint> {
1334
+ ): Promise<BridgeCapacity> {
1154
1335
  const sourceToken = this.getTokenForChain(sourceChain);
1155
1336
  const targetToken = this.getTokenForChain(targetChain);
1156
-
1157
- if (!sourceToken || !targetToken) return 0n;
1158
-
1159
- // Only applies to native tokens (need gas from same balance)
1160
- if (!isNativeTokenStandard(sourceToken.standard)) {
1161
- return rawInventory; // ERC20s don't compete with gas
1162
- }
1337
+ assert(sourceToken, `No token found for source chain: ${sourceChain}`);
1338
+ assert(targetToken, `No token found for target chain: ${targetChain}`);
1163
1339
 
1164
1340
  // Convert HypNative token addresses to the external bridge's native token representation
1165
- const fromTokenAddress = this.getNativeTokenAddress(externalBridgeType);
1341
+ const fromTokenAddress = getExternalBridgeTokenAddress(
1342
+ sourceToken,
1343
+ externalBridgeType,
1344
+ this.getNativeTokenAddress.bind(this),
1345
+ );
1166
1346
  const toTokenAddress = getExternalBridgeTokenAddress(
1167
1347
  targetToken,
1168
1348
  externalBridgeType,
@@ -1174,7 +1354,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1174
1354
 
1175
1355
  try {
1176
1356
  const externalBridge = this.getExternalBridge(externalBridgeType);
1177
- const quote = await externalBridge.quote({
1357
+ const initialQuote = await externalBridge.quote({
1178
1358
  fromChain: sourceChainId,
1179
1359
  toChain: targetChainId,
1180
1360
  fromToken: fromTokenAddress,
@@ -1184,48 +1364,58 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1184
1364
  toAddress: this.getInventorySignerAddress(targetChain),
1185
1365
  });
1186
1366
 
1187
- // Apply 20x multiplier on quoted gas (LiFi underestimates by ~14x)
1188
- const estimatedGas = quote.gasCosts * GAS_COST_MULTIPLIER;
1367
+ let maxSourceInput = rawInventory;
1368
+ let outputQuote = initialQuote;
1189
1369
 
1190
- // Viability check: gas should not exceed 10% of inventory
1191
- const maxGasThreshold = rawInventory / MAX_GAS_PERCENT_THRESHOLD;
1192
- if (estimatedGas > maxGasThreshold) {
1193
- this.logger.info(
1194
- {
1195
- sourceChain,
1196
- targetChain,
1197
- rawInventory: rawInventory.toString(),
1198
- rawInventoryEth: (Number(rawInventory) / 1e18).toFixed(6),
1199
- quotedGas: quote.gasCosts.toString(),
1200
- estimatedGas: estimatedGas.toString(),
1201
- estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
1202
- maxGasThreshold: maxGasThreshold.toString(),
1203
- gasPercent: `${(Number(estimatedGas) * 100) / Number(rawInventory)}%`,
1204
- },
1205
- 'Bridge not viable - gas cost exceeds 10% of inventory',
1206
- );
1207
- return 0n;
1208
- }
1370
+ if (isNativeTokenStandard(sourceToken.standard)) {
1371
+ const estimatedGas = initialQuote.gasCosts * GAS_COST_MULTIPLIER;
1372
+ const maxGasThreshold = rawInventory / MAX_GAS_PERCENT_THRESHOLD;
1373
+ if (estimatedGas > maxGasThreshold) {
1374
+ this.logger.info(
1375
+ {
1376
+ sourceChain,
1377
+ targetChain,
1378
+ rawInventory: rawInventory.toString(),
1379
+ quotedGas: initialQuote.gasCosts.toString(),
1380
+ estimatedGas: estimatedGas.toString(),
1381
+ maxGasThreshold: maxGasThreshold.toString(),
1382
+ },
1383
+ 'Bridge not viable - gas cost exceeds 10% of inventory',
1384
+ );
1385
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
1386
+ }
1209
1387
 
1210
- // Max viable = inventory minus estimated gas
1211
- const maxViable = rawInventory - estimatedGas;
1388
+ maxSourceInput = rawInventory - estimatedGas;
1389
+ if (maxSourceInput <= 0n) {
1390
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
1391
+ }
1392
+
1393
+ outputQuote = await externalBridge.quote({
1394
+ fromChain: sourceChainId,
1395
+ toChain: targetChainId,
1396
+ fromToken: fromTokenAddress,
1397
+ toToken: toTokenAddress,
1398
+ fromAmount: maxSourceInput,
1399
+ fromAddress: this.getInventorySignerAddress(sourceChain),
1400
+ toAddress: this.getInventorySignerAddress(targetChain),
1401
+ });
1402
+ }
1212
1403
 
1213
1404
  this.logger.info(
1214
1405
  {
1215
1406
  sourceChain,
1216
1407
  targetChain,
1217
1408
  rawInventory: rawInventory.toString(),
1218
- rawInventoryEth: (Number(rawInventory) / 1e18).toFixed(6),
1219
- quotedGas: quote.gasCosts.toString(),
1220
- estimatedGas: estimatedGas.toString(),
1221
- estimatedGasEth: (Number(estimatedGas) / 1e18).toFixed(6),
1222
- maxViable: maxViable.toString(),
1223
- maxViableEth: (Number(maxViable) / 1e18).toFixed(6),
1409
+ maxSourceInput: maxSourceInput.toString(),
1410
+ maxTargetOutput: outputQuote.toAmountMin.toString(),
1224
1411
  },
1225
- 'Calculated max viable bridge amount',
1412
+ 'Calculated bridge capacity',
1226
1413
  );
1227
1414
 
1228
- return maxViable;
1415
+ return {
1416
+ maxSourceInput,
1417
+ maxTargetOutput: outputQuote.toAmountMin,
1418
+ };
1229
1419
  } catch (error) {
1230
1420
  this.logger.warn(
1231
1421
  {
@@ -1233,21 +1423,24 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1233
1423
  targetChain,
1234
1424
  error: (error as Error).message,
1235
1425
  },
1236
- 'Failed to calculate max viable bridge amount, skipping chain',
1426
+ 'Failed to calculate bridge capacity, skipping chain',
1237
1427
  );
1238
- return 0n;
1428
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
1239
1429
  }
1240
1430
  }
1241
1431
 
1242
1432
  /**
1243
1433
  * Execute inventory movement from source chain to target chain via LiFi bridge.
1244
1434
  *
1245
- * IMPORTANT: The amount parameter is now the MAX VIABLE amount (gas already subtracted
1246
- * by calculateMaxViableBridgeAmount). This method trusts that the amount is pre-validated.
1435
+ * Quote mode is chosen during planning:
1436
+ * - `reverse`: request an exact target-chain output when the source has headroom
1437
+ * - `forward`: spend the source cap directly when source inventory is the limiter
1247
1438
  *
1248
1439
  * @param sourceChain - Chain to move inventory from
1249
1440
  * @param targetChain - Chain to move inventory to (origin chain for rebalancing)
1250
- * @param amount - Pre-validated amount to bridge (gas already accounted for)
1441
+ * @param targetOutputAmount - Destination-local amount to receive
1442
+ * @param maxSourceInput - Maximum source-local amount available for this plan
1443
+ * @param quoteMode - Whether to execute this bridge plan as exact-input or exact-output
1251
1444
  * @param intent - Rebalance intent for tracking
1252
1445
  * @param externalBridgeType - External bridge type to use
1253
1446
  * @returns Result with success status and optional txHash/error
@@ -1255,10 +1448,12 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1255
1448
  private async executeInventoryMovement(
1256
1449
  sourceChain: ChainName,
1257
1450
  targetChain: ChainName,
1258
- amount: bigint,
1451
+ targetOutputAmount: bigint,
1452
+ maxSourceInput: bigint,
1453
+ quoteMode: BridgeQuoteMode,
1259
1454
  intent: RebalanceIntent,
1260
1455
  externalBridgeType: ExternalBridgeType,
1261
- ): Promise<{ success: boolean; txHash?: string; error?: string }> {
1456
+ ): Promise<InventoryMovementExecutionResult> {
1262
1457
  const sourceToken = this.getTokenForChain(sourceChain);
1263
1458
  if (!sourceToken) {
1264
1459
  return {
@@ -1304,57 +1499,53 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1304
1499
  'Resolved token addresses for LiFi bridge',
1305
1500
  );
1306
1501
 
1307
- // Calculate minViableTransfer for the target chain
1308
- // If bridging less than this, the received amount won't be enough to execute transferRemote
1309
- // So we over-bridge to ensure we can complete the intent in the next cycle
1310
- const costs = await calculateTransferCosts(
1311
- targetChain, // FROM chain for transferRemote (the target of this bridge)
1312
- sourceChain, // TO chain for transferRemote (Hyperlane message destination)
1313
- amount, // availableInventory (not used for minViableTransfer calculation)
1314
- amount, // requestedAmount
1315
- this.multiProvider,
1316
- this.warpCore.multiProvider,
1317
- this.getTokenForChain.bind(this),
1318
- this.getInventorySignerAddress(targetChain),
1319
- isNativeTokenStandard,
1320
- this.logger,
1321
- );
1322
- const { minViableTransfer } = costs;
1502
+ try {
1503
+ const externalBridge = this.getExternalBridge(externalBridgeType);
1504
+ const fromAddress = this.getInventorySignerAddress(sourceChain);
1505
+ const toAddress = this.getInventorySignerAddress(targetChain);
1506
+ const quoteWithMode = async (mode: BridgeQuoteMode) =>
1507
+ externalBridge.quote({
1508
+ fromChain: sourceChainId,
1509
+ toChain: targetChainId,
1510
+ fromToken: fromTokenAddress,
1511
+ toToken: toTokenAddress,
1512
+ ...(mode === 'forward'
1513
+ ? { fromAmount: maxSourceInput }
1514
+ : { toAmount: targetOutputAmount }),
1515
+ fromAddress,
1516
+ toAddress,
1517
+ });
1323
1518
 
1324
- // If the requested amount is below minViableTransfer, adjust it up
1325
- // This ensures we bridge enough to actually complete the final transferRemote
1326
- const effectiveAmount =
1327
- amount < minViableTransfer ? minViableTransfer : amount;
1519
+ let quoteModeUsed = quoteMode;
1520
+ let quote = await quoteWithMode(quoteModeUsed);
1328
1521
 
1329
- if (effectiveAmount !== amount) {
1330
- this.logger.info(
1331
- {
1332
- originalAmount: amount.toString(),
1333
- effectiveAmount: effectiveAmount.toString(),
1334
- minViableTransfer: minViableTransfer.toString(),
1335
- originalAmountEth: (Number(amount) / 1e18).toFixed(6),
1336
- effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
1337
- minViableTransferEth: (Number(minViableTransfer) / 1e18).toFixed(6),
1338
- adjustedUp: true,
1339
- intentId: intent.id,
1340
- },
1341
- 'Over-bridging to minViableTransfer to ensure final transferRemote can complete',
1342
- );
1343
- }
1522
+ if (quoteModeUsed === 'reverse' && quote.fromAmount > maxSourceInput) {
1523
+ this.logger.warn(
1524
+ {
1525
+ sourceChain,
1526
+ targetChain,
1527
+ plannedQuoteMode: quoteMode,
1528
+ requestedTargetOutput: targetOutputAmount.toString(),
1529
+ quotedInput: quote.fromAmount.toString(),
1530
+ maxSourceInput: maxSourceInput.toString(),
1531
+ intentId: intent.id,
1532
+ },
1533
+ 'Reverse bridge quote exceeded source capacity, retrying with forward quote',
1534
+ );
1344
1535
 
1345
- try {
1346
- const externalBridge = this.getExternalBridge(externalBridgeType);
1347
- const quote = await externalBridge.quote({
1348
- fromChain: sourceChainId,
1349
- toChain: targetChainId,
1350
- fromToken: fromTokenAddress,
1351
- toToken: toTokenAddress,
1352
- fromAmount: effectiveAmount,
1353
- fromAddress: this.getInventorySignerAddress(sourceChain),
1354
- toAddress: this.getInventorySignerAddress(targetChain),
1355
- });
1536
+ // Spend the full source cap on fallback; minor output drift is acceptable
1537
+ // and will be reconciled by later cycles rather than risking livelock.
1538
+ quoteModeUsed = 'forward';
1539
+ quote = await quoteWithMode(quoteModeUsed);
1540
+ }
1356
1541
 
1357
1542
  const inputRequired = quote.fromAmount;
1543
+ if (inputRequired > maxSourceInput) {
1544
+ return {
1545
+ success: false,
1546
+ error: `Bridge input ${inputRequired} exceeded planned source capacity ${maxSourceInput}`,
1547
+ };
1548
+ }
1358
1549
 
1359
1550
  this.logger.info(
1360
1551
  {
@@ -1362,19 +1553,35 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1362
1553
  targetChain,
1363
1554
  sourceChainId,
1364
1555
  targetChainId,
1365
- preValidatedAmount: amount.toString(),
1366
- preValidatedAmountEth: (Number(amount) / 1e18).toFixed(6),
1367
- effectiveAmount: effectiveAmount.toString(),
1368
- effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
1556
+ requestedTargetOutput: targetOutputAmount.toString(),
1557
+ requestedTargetOutputFormatted: this.formatLocalAmount(
1558
+ targetOutputAmount,
1559
+ targetToken,
1560
+ ),
1561
+ quoteModePlanned: quoteMode,
1562
+ quoteModeUsed,
1563
+ retriedAsForward:
1564
+ quoteMode === 'reverse' && quoteModeUsed === 'forward',
1369
1565
  inputRequired: inputRequired.toString(),
1370
- expectedOutput: quote.toAmount.toString(),
1371
- expectedOutputEth: (Number(quote.toAmount) / 1e18).toFixed(6),
1566
+ inputRequiredFormatted: this.formatLocalAmount(
1567
+ inputRequired,
1568
+ sourceToken,
1569
+ ),
1570
+ quotedOutput: quote.toAmount.toString(),
1571
+ quotedOutputMin: quote.toAmountMin.toString(),
1572
+ quotedOutputFormatted: this.formatLocalAmount(
1573
+ quote.toAmount,
1574
+ targetToken,
1575
+ ),
1576
+ quotedOutputMinFormatted: this.formatLocalAmount(
1577
+ quote.toAmountMin,
1578
+ targetToken,
1579
+ ),
1372
1580
  gasCosts: quote.gasCosts.toString(),
1373
1581
  feeCosts: quote.feeCosts.toString(),
1374
1582
  intentId: intent.id,
1375
- adjustedForMinViable: effectiveAmount > amount,
1376
1583
  },
1377
- 'Executing inventory movement via LiFi with pre-validated amount',
1584
+ 'Executing inventory movement via bridge quote',
1378
1585
  );
1379
1586
 
1380
1587
  this.logger.debug(
@@ -1417,6 +1624,8 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1417
1624
  'Inventory movement transaction executed',
1418
1625
  );
1419
1626
 
1627
+ // Keep bridge consumption in source-local units; intent fulfillment only
1628
+ // advances from canonical inventory_deposit amounts after transferRemote.
1420
1629
  await this.actionTracker.createRebalanceAction({
1421
1630
  intentId: intent.id,
1422
1631
  origin: this.multiProvider.getDomainId(sourceChain),
@@ -1440,22 +1649,31 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1440
1649
  'Updated consumed inventory after LiFi bridge',
1441
1650
  );
1442
1651
 
1443
- return { success: true, txHash: result.txHash };
1652
+ return {
1653
+ success: true,
1654
+ txHash: result.txHash,
1655
+ inputRequired,
1656
+ quotedOutput: quote.toAmount,
1657
+ quotedOutputMin: quote.toAmountMin,
1658
+ quoteModeUsed,
1659
+ };
1444
1660
  } catch (error) {
1661
+ const errorMessage =
1662
+ error instanceof Error ? error.message : String(error);
1445
1663
  this.logger.error(
1446
1664
  {
1447
1665
  sourceChain,
1448
1666
  targetChain,
1449
- amount: amount.toString(),
1667
+ amount: targetOutputAmount.toString(),
1450
1668
  intentId: intent.id,
1451
- error: (error as Error).message,
1669
+ error: errorMessage,
1452
1670
  },
1453
1671
  'Failed to execute inventory movement',
1454
1672
  );
1455
1673
 
1456
1674
  return {
1457
1675
  success: false,
1458
- error: (error as Error).message,
1676
+ error: errorMessage,
1459
1677
  };
1460
1678
  }
1461
1679
  }