@hyperlane-xyz/rebalancer 27.2.11 → 27.2.13

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 (91) hide show
  1. package/dist/core/InventoryRebalancer.d.ts +11 -19
  2. package/dist/core/InventoryRebalancer.d.ts.map +1 -1
  3. package/dist/core/InventoryRebalancer.js +336 -268
  4. package/dist/core/InventoryRebalancer.js.map +1 -1
  5. package/dist/core/InventoryRebalancer.test.js +397 -23
  6. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  7. package/dist/core/Rebalancer.d.ts.map +1 -1
  8. package/dist/core/Rebalancer.js +12 -6
  9. package/dist/core/Rebalancer.js.map +1 -1
  10. package/dist/core/Rebalancer.test.js +51 -0
  11. package/dist/core/Rebalancer.test.js.map +1 -1
  12. package/dist/core/RebalancerOrchestrator.test.js +0 -1
  13. package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
  14. package/dist/core/RebalancerService.d.ts +2 -3
  15. package/dist/core/RebalancerService.d.ts.map +1 -1
  16. package/dist/core/RebalancerService.js +3 -2
  17. package/dist/core/RebalancerService.js.map +1 -1
  18. package/dist/core/RebalancerService.test.js +24 -0
  19. package/dist/core/RebalancerService.test.js.map +1 -1
  20. package/dist/e2e/harness/TestHelpers.js +1 -2
  21. package/dist/e2e/harness/TestHelpers.js.map +1 -1
  22. package/dist/factories/RebalancerContextFactory.d.ts +4 -5
  23. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  24. package/dist/factories/RebalancerContextFactory.js +12 -7
  25. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  26. package/dist/factories/RebalancerContextFactory.test.js +99 -2
  27. package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
  28. package/dist/interfaces/IRebalancer.d.ts +4 -2
  29. package/dist/interfaces/IRebalancer.d.ts.map +1 -1
  30. package/dist/metrics/scripts/metrics.d.ts +1 -1
  31. package/dist/monitor/Monitor.d.ts.map +1 -1
  32. package/dist/monitor/Monitor.js +14 -6
  33. package/dist/monitor/Monitor.js.map +1 -1
  34. package/dist/strategy/BaseStrategy.d.ts.map +1 -1
  35. package/dist/strategy/BaseStrategy.js +13 -11
  36. package/dist/strategy/BaseStrategy.js.map +1 -1
  37. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
  38. package/dist/strategy/CollateralDeficitStrategy.js +2 -2
  39. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
  40. package/dist/strategy/MinAmountStrategy.d.ts +1 -0
  41. package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
  42. package/dist/strategy/MinAmountStrategy.js +12 -8
  43. package/dist/strategy/MinAmountStrategy.js.map +1 -1
  44. package/dist/strategy/MinAmountStrategy.test.js +189 -2
  45. package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
  46. package/dist/test/helpers.d.ts +11 -3
  47. package/dist/test/helpers.d.ts.map +1 -1
  48. package/dist/test/helpers.js +9 -11
  49. package/dist/test/helpers.js.map +1 -1
  50. package/dist/test/lifiMocks.d.ts.map +1 -1
  51. package/dist/test/lifiMocks.js +5 -2
  52. package/dist/test/lifiMocks.js.map +1 -1
  53. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  54. package/dist/tracking/ActionTracker.js +2 -1
  55. package/dist/tracking/ActionTracker.js.map +1 -1
  56. package/dist/tracking/ActionTracker.test.js +39 -0
  57. package/dist/tracking/ActionTracker.test.js.map +1 -1
  58. package/dist/utils/balanceUtils.d.ts +7 -1
  59. package/dist/utils/balanceUtils.d.ts.map +1 -1
  60. package/dist/utils/balanceUtils.js +39 -1
  61. package/dist/utils/balanceUtils.js.map +1 -1
  62. package/dist/utils/balanceUtils.test.js +55 -1
  63. package/dist/utils/balanceUtils.test.js.map +1 -1
  64. package/dist/utils/blockTag.d.ts +3 -3
  65. package/dist/utils/blockTag.d.ts.map +1 -1
  66. package/dist/utils/blockTag.js +1 -1
  67. package/dist/utils/blockTag.js.map +1 -1
  68. package/package.json +7 -7
  69. package/src/core/InventoryRebalancer.test.ts +503 -38
  70. package/src/core/InventoryRebalancer.ts +483 -350
  71. package/src/core/Rebalancer.test.ts +84 -0
  72. package/src/core/Rebalancer.ts +22 -6
  73. package/src/core/RebalancerOrchestrator.test.ts +0 -1
  74. package/src/core/RebalancerService.test.ts +35 -0
  75. package/src/core/RebalancerService.ts +9 -5
  76. package/src/e2e/harness/TestHelpers.ts +3 -3
  77. package/src/factories/RebalancerContextFactory.test.ts +143 -6
  78. package/src/factories/RebalancerContextFactory.ts +29 -17
  79. package/src/interfaces/IRebalancer.ts +4 -1
  80. package/src/monitor/Monitor.ts +19 -6
  81. package/src/strategy/BaseStrategy.ts +18 -15
  82. package/src/strategy/CollateralDeficitStrategy.ts +4 -3
  83. package/src/strategy/MinAmountStrategy.test.ts +238 -2
  84. package/src/strategy/MinAmountStrategy.ts +29 -17
  85. package/src/test/helpers.ts +13 -12
  86. package/src/test/lifiMocks.ts +5 -2
  87. package/src/tracking/ActionTracker.test.ts +47 -0
  88. package/src/tracking/ActionTracker.ts +2 -1
  89. package/src/utils/balanceUtils.test.ts +87 -1
  90. package/src/utils/balanceUtils.ts +73 -2
  91. 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,71 @@ 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
+ const RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES = [
78
+ 'balance may be insufficient',
79
+ 'transfer amount exceeds balance',
80
+ 'insufficient balance',
81
+ ];
82
+
83
+ function hasRecoverableMaxTransferErrorMessage(message: string): boolean {
84
+ const normalized = message.toLowerCase();
85
+ return (
86
+ normalized.includes('unpredictable_gas_limit') ||
87
+ RECOVERABLE_MAX_TRANSFER_ERROR_MESSAGES.some((pattern) =>
88
+ normalized.includes(pattern),
89
+ )
90
+ );
91
+ }
92
+
93
+ function isRecoverableMaxTransferProbeError(error: unknown): boolean {
94
+ const seen = new Set<unknown>();
95
+ const stack: unknown[] = [error];
96
+
97
+ while (stack.length > 0) {
98
+ const current = stack.pop();
99
+ if (current == null) continue;
100
+
101
+ if (typeof current === 'string') {
102
+ if (hasRecoverableMaxTransferErrorMessage(current)) return true;
103
+ continue;
104
+ }
105
+
106
+ if (typeof current !== 'object') continue;
107
+ if (seen.has(current)) continue;
108
+ seen.add(current);
109
+
110
+ const candidate = current as {
111
+ code?: unknown;
112
+ message?: unknown;
113
+ cause?: unknown;
114
+ error?: unknown;
115
+ };
116
+
117
+ if (
118
+ typeof candidate.code === 'string' &&
119
+ candidate.code.toUpperCase() === 'UNPREDICTABLE_GAS_LIMIT'
120
+ ) {
121
+ return true;
122
+ }
123
+
124
+ if (
125
+ typeof candidate.message === 'string' &&
126
+ hasRecoverableMaxTransferErrorMessage(candidate.message)
127
+ ) {
128
+ return true;
129
+ }
130
+
131
+ stack.push(candidate.cause, candidate.error);
132
+ }
133
+
134
+ return false;
135
+ }
136
+
75
137
  /**
76
138
  * Configuration for the InventoryRebalancer.
77
139
  */
@@ -288,6 +350,10 @@ export class InventoryRebalancer implements IInventoryRebalancer {
288
350
  return total;
289
351
  }
290
352
 
353
+ private formatLocalAmount(amount: bigint, token: Token): string {
354
+ return fromWei(amount.toString(), token.decimals);
355
+ }
356
+
291
357
  /**
292
358
  * Get the effective available inventory for a chain, accounting for
293
359
  * inventory already consumed during this execution cycle.
@@ -533,12 +599,18 @@ export class InventoryRebalancer implements IInventoryRebalancer {
533
599
  {
534
600
  strategyRoute: `${origin} (surplus) → ${destination} (deficit)`,
535
601
  executionRoute: `transferRemote FROM ${destination} TO ${origin}`,
536
- amount: amount.toString(),
602
+ canonicalAmount: amount.toString(),
537
603
  intentId: intent.id,
538
604
  },
539
605
  'Executing inventory route',
540
606
  );
541
607
 
608
+ const sourceToken = this.getTokenForChain(destination);
609
+ assert(sourceToken, `No token found for source chain: ${destination}`);
610
+ const requestedLocalAmount = denormalizeToLocal(amount, sourceToken);
611
+ const executionSender = this.getInventorySignerAddress(destination);
612
+ const executionRecipient = this.getInventorySignerAddress(origin);
613
+
542
614
  // Check available inventory on the DESTINATION (deficit) chain
543
615
  // We need inventory here because transferRemote is called FROM this chain
544
616
  const availableInventory = this.getEffectiveAvailableInventory(destination);
@@ -547,9 +619,15 @@ export class InventoryRebalancer implements IInventoryRebalancer {
547
619
  {
548
620
  checkingChain: destination,
549
621
  availableInventory: availableInventory.toString(),
550
- availableInventoryEth: (Number(availableInventory) / 1e18).toFixed(6),
551
- requiredAmount: amount.toString(),
552
- requiredAmountEth: (Number(amount) / 1e18).toFixed(6),
622
+ availableInventoryFormatted: this.formatLocalAmount(
623
+ availableInventory,
624
+ sourceToken,
625
+ ),
626
+ requiredAmount: requestedLocalAmount.toString(),
627
+ requiredAmountFormatted: this.formatLocalAmount(
628
+ requestedLocalAmount,
629
+ sourceToken,
630
+ ),
553
631
  },
554
632
  'Checking effective inventory on destination (deficit) chain',
555
633
  );
@@ -560,15 +638,72 @@ export class InventoryRebalancer implements IInventoryRebalancer {
560
638
  destination, // FROM chain (where transferRemote is called)
561
639
  origin, // TO chain (where Hyperlane message goes)
562
640
  availableInventory,
563
- amount,
641
+ requestedLocalAmount,
564
642
  this.multiProvider,
565
643
  this.warpCore.multiProvider,
566
644
  this.getTokenForChain.bind(this),
567
- this.getInventorySignerAddress(destination),
645
+ executionSender,
568
646
  isNativeTokenStandard,
569
647
  this.logger,
570
648
  );
571
- const { maxTransferable, minViableTransfer } = costs;
649
+ const { minViableTransfer } = costs;
650
+ let maxTransferable = costs.maxTransferable;
651
+
652
+ if (!isNativeTokenStandard(sourceToken.standard)) {
653
+ if (availableInventory === 0n) {
654
+ maxTransferable = 0n;
655
+ this.logger.debug(
656
+ {
657
+ fromChain: destination,
658
+ toChain: origin,
659
+ requestedAmount: requestedLocalAmount.toString(),
660
+ },
661
+ 'Skipping fee-aware max transferable probe because destination inventory is zero',
662
+ );
663
+ } else {
664
+ try {
665
+ const feeAwareMaxTransfer = await this.warpCore.getMaxTransferAmount({
666
+ balance: sourceToken.amount(availableInventory),
667
+ destination: origin,
668
+ sender: executionSender,
669
+ recipient: executionRecipient,
670
+ });
671
+
672
+ maxTransferable =
673
+ feeAwareMaxTransfer.amount < requestedLocalAmount
674
+ ? feeAwareMaxTransfer.amount
675
+ : requestedLocalAmount;
676
+
677
+ this.logger.debug(
678
+ {
679
+ fromChain: destination,
680
+ toChain: origin,
681
+ availableInventory: availableInventory.toString(),
682
+ requestedAmount: requestedLocalAmount.toString(),
683
+ feeAwareMaxTransferable: maxTransferable.toString(),
684
+ },
685
+ 'Calculated fee-aware max transferable amount for non-native route',
686
+ );
687
+ } catch (error) {
688
+ if (!isRecoverableMaxTransferProbeError(error)) {
689
+ throw error;
690
+ }
691
+
692
+ maxTransferable = 0n;
693
+ this.logger.warn(
694
+ {
695
+ fromChain: destination,
696
+ toChain: origin,
697
+ availableInventory: availableInventory.toString(),
698
+ requestedAmount: requestedLocalAmount.toString(),
699
+ error: error instanceof Error ? error.message : String(error),
700
+ intentId: intent.id,
701
+ },
702
+ 'Fee-aware max transferable probe failed due to insufficient balance, falling back to external bridge',
703
+ );
704
+ }
705
+ }
706
+ }
572
707
 
573
708
  // Calculate total inventory across all chains
574
709
  // Note: consumedInventory tracking is handled separately within this cycle
@@ -578,24 +713,36 @@ export class InventoryRebalancer implements IInventoryRebalancer {
578
713
  {
579
714
  fromChain: destination,
580
715
  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,
716
+ availableInventoryFormatted: this.formatLocalAmount(
717
+ availableInventory,
718
+ sourceToken,
719
+ ),
720
+ requestedAmountFormatted: this.formatLocalAmount(
721
+ requestedLocalAmount,
722
+ sourceToken,
723
+ ),
724
+ maxTransferableFormatted: this.formatLocalAmount(
725
+ maxTransferable,
726
+ sourceToken,
727
+ ),
728
+ minViableTransferFormatted: this.formatLocalAmount(
729
+ minViableTransfer,
730
+ sourceToken,
731
+ ),
732
+ canFullyFulfill: maxTransferable >= requestedLocalAmount,
587
733
  canPartialFulfill: maxTransferable >= minViableTransfer,
734
+ totalInventory: totalInventory.toString(),
588
735
  },
589
736
  'Calculated max transferable amount with cost-based threshold',
590
737
  );
591
738
 
592
739
  // Early exit: If remaining amount is below minViableTransfer, complete the intent
593
740
  // This prevents infinite loops when the remaining amount is too small to economically bridge
594
- if (amount < minViableTransfer) {
741
+ if (requestedLocalAmount < minViableTransfer) {
595
742
  this.logger.info(
596
743
  {
597
744
  intentId: intent.id,
598
- amount: amount.toString(),
745
+ amount: requestedLocalAmount.toString(),
599
746
  minViableTransfer: minViableTransfer.toString(),
600
747
  },
601
748
  'Remaining amount below minViableTransfer, completing intent with acceptable loss',
@@ -616,227 +763,270 @@ export class InventoryRebalancer implements IInventoryRebalancer {
616
763
  ...route,
617
764
  origin: destination, // transferRemote called FROM here
618
765
  destination: origin, // Hyperlane message goes TO here
766
+ amount: requestedLocalAmount,
619
767
  };
620
768
 
621
- if (maxTransferable >= amount) {
769
+ if (maxTransferable >= requestedLocalAmount) {
622
770
  // Sufficient inventory on destination - execute transferRemote directly
771
+ const fulfilledCanonicalAmount = normalizeToCanonical(
772
+ requestedLocalAmount,
773
+ sourceToken,
774
+ );
623
775
  const result = await this.executeTransferRemote(
624
776
  swappedRoute,
625
777
  intent,
626
- costs.gasQuote!,
778
+ fulfilledCanonicalAmount,
627
779
  );
628
780
  // Return original strategy route in result (not the swapped execution route)
629
781
  return { ...result, route };
630
782
  } else if (maxTransferable > 0n && maxTransferable >= minViableTransfer) {
631
783
  // 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',
784
+ const alignedExecution = alignLocalToCanonical(
785
+ maxTransferable,
786
+ sourceToken,
665
787
  );
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(
788
+ if (alignedExecution.messageAmount === 0n) {
789
+ this.logger.info(
672
790
  {
673
- origin,
674
- destination,
675
- amount: amount.toString(),
676
791
  intentId: intent.id,
792
+ maxTransferable: maxTransferable.toString(),
677
793
  },
678
- 'No inventory available on any monitored chain',
794
+ 'Skipping partial transferRemote because available local amount cannot produce canonical progress',
679
795
  );
680
-
681
- return {
682
- route,
683
- success: false,
684
- error: 'No inventory available on any monitored chain',
796
+ } else {
797
+ const partialSwappedRoute: InventoryRoute = {
798
+ ...swappedRoute,
799
+ amount: alignedExecution.localAmount,
685
800
  };
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,
801
+ const result = await this.executeTransferRemote(
802
+ partialSwappedRoute,
803
+ intent,
804
+ alignedExecution.messageAmount,
698
805
  );
699
806
 
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(
807
+ this.logger.info(
710
808
  {
711
- targetChain: destination,
712
- sourcesChecked: allSources.length,
713
809
  intentId: intent.id,
810
+ partialAmount: alignedExecution.localAmount.toString(),
811
+ partialAmountCanonical: alignedExecution.messageAmount.toString(),
812
+ requestedAmount: requestedLocalAmount.toString(),
813
+ requestedAmountCanonical: amount.toString(),
814
+ remainingAmountCanonical: (amount > alignedExecution.messageAmount
815
+ ? amount - alignedExecution.messageAmount
816
+ : 0n
817
+ ).toString(),
714
818
  },
715
- 'No viable bridge sources - all chains have insufficient inventory or high gas costs',
819
+ 'Executed partial inventory deposit, remaining will be handled in future cycles',
716
820
  );
717
821
 
718
- return {
719
- route,
720
- success: false,
721
- error: 'No viable bridge sources available',
722
- };
822
+ // Return original strategy route in result (not the swapped execution route)
823
+ return { ...result, route };
723
824
  }
825
+ }
724
826
 
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;
730
-
731
- for (const source of viableSources) {
732
- if (totalPlanned >= targetWithBuffer) break;
733
-
734
- const remaining = targetWithBuffer - totalPlanned;
735
- const amountFromSource =
736
- source.maxViable >= remaining ? remaining : source.maxViable; // Already gas-adjusted!
827
+ // Inventory below cost-based threshold - trigger ExternalBridge movement TO destination chain
828
+ this.logger.info(
829
+ {
830
+ targetChain: destination,
831
+ maxTransferable: maxTransferable.toString(),
832
+ minViableTransfer: minViableTransfer.toString(),
833
+ costMultiplier: MIN_VIABLE_COST_MULTIPLIER.toString(),
834
+ intentId: intent.id,
835
+ },
836
+ 'Inventory below cost-based threshold on destination, triggering LiFi movement',
837
+ );
737
838
 
738
- bridgePlans.push({
739
- chain: source.chain,
740
- amount: amountFromSource,
741
- });
742
- totalPlanned += amountFromSource;
743
- }
839
+ // Get all available source chains with raw inventory
840
+ const allSources = this.selectAllSourceChains(destination);
744
841
 
745
- this.logger.info(
842
+ if (allSources.length === 0) {
843
+ this.logger.warn(
746
844
  {
747
- 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(),
845
+ origin,
846
+ destination,
847
+ amount: requestedLocalAmount.toString(),
760
848
  intentId: intent.id,
761
849
  },
762
- 'Created bridge plans using gas-adjusted viable amounts',
850
+ 'No inventory available on any monitored chain',
763
851
  );
764
852
 
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
- ),
775
- ),
776
- );
853
+ return {
854
+ route,
855
+ success: false,
856
+ error: 'No inventory available on any monitored chain',
857
+ };
858
+ }
777
859
 
778
- // Process results
779
- let successCount = 0;
780
- let totalBridged = 0n;
781
- const failedErrors: string[] = [];
860
+ // Calculate source capacities in destination-local units.
861
+ const viableSources: Array<{
862
+ chain: ChainName;
863
+ maxSourceInput: bigint;
864
+ maxTargetOutput: bigint;
865
+ }> = [];
782
866
 
783
- for (let i = 0; i < bridgeResults.length; i++) {
784
- const result = bridgeResults[i];
785
- const plan = bridgePlans[i];
867
+ for (const source of allSources) {
868
+ const capacity = await this.calculateBridgeCapacity(
869
+ source.chain,
870
+ destination,
871
+ source.availableAmount,
872
+ route.externalBridge,
873
+ );
786
874
 
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}`);
805
- }
806
- this.logger.warn(
807
- {
808
- sourceChain: plan.chain,
809
- amount: plan.amount.toString(),
810
- error,
811
- },
812
- 'Inventory movement failed',
813
- );
814
- }
875
+ if (capacity.maxTargetOutput > 0n) {
876
+ viableSources.push({ chain: source.chain, ...capacity });
815
877
  }
878
+ }
816
879
 
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
- }
880
+ // Sort by destination output descending.
881
+ viableSources.sort((a, b) =>
882
+ a.maxTargetOutput > b.maxTargetOutput ? -1 : 1,
883
+ );
826
884
 
827
- this.logger.info(
885
+ if (viableSources.length === 0) {
886
+ this.logger.warn(
828
887
  {
829
888
  targetChain: destination,
830
- successCount,
831
- totalBridged: totalBridged.toString(),
832
- targetAmount: amount.toString(),
889
+ sourcesChecked: allSources.length,
833
890
  intentId: intent.id,
834
891
  },
835
- 'Parallel inventory movements completed, transferRemote will execute after bridges complete',
892
+ 'No viable bridge sources - all chains have insufficient inventory or high gas costs',
836
893
  );
837
894
 
838
- return { route, success: true };
895
+ return {
896
+ route,
897
+ success: false,
898
+ error: 'No viable bridge sources available',
899
+ };
839
900
  }
901
+
902
+ // Create bridge plans using destination-local output amounts.
903
+ const shortfall =
904
+ requestedLocalAmount > availableInventory
905
+ ? requestedLocalAmount - availableInventory
906
+ : 0n;
907
+ const targetWithBuffer =
908
+ ((shortfall + costs.totalCost) * (100n + BRIDGE_BUFFER_PERCENT)) / 100n;
909
+ const bridgePlans: Array<{
910
+ chain: ChainName;
911
+ maxSourceInput: bigint;
912
+ targetOutput: bigint;
913
+ }> = [];
914
+ let totalPlanned = 0n;
915
+
916
+ for (const source of viableSources) {
917
+ if (totalPlanned >= targetWithBuffer) break;
918
+
919
+ const remaining = targetWithBuffer - totalPlanned;
920
+ const targetOutput =
921
+ source.maxTargetOutput >= remaining
922
+ ? remaining
923
+ : source.maxTargetOutput;
924
+
925
+ bridgePlans.push({
926
+ chain: source.chain,
927
+ maxSourceInput: source.maxSourceInput,
928
+ targetOutput,
929
+ });
930
+ totalPlanned += targetOutput;
931
+ }
932
+
933
+ this.logger.info(
934
+ {
935
+ targetChain: destination,
936
+ viableSources: viableSources.map((s) => ({
937
+ chain: s.chain,
938
+ maxSourceInput: s.maxSourceInput.toString(),
939
+ maxTargetOutput: s.maxTargetOutput.toString(),
940
+ })),
941
+ bridgePlans: bridgePlans.map((p) => ({
942
+ chain: p.chain,
943
+ maxSourceInput: p.maxSourceInput.toString(),
944
+ targetOutput: p.targetOutput.toString(),
945
+ })),
946
+ totalPlanned: totalPlanned.toString(),
947
+ shortfall: shortfall.toString(),
948
+ targetWithBuffer: targetWithBuffer.toString(),
949
+ intentId: intent.id,
950
+ },
951
+ 'Created bridge plans using gas-adjusted viable amounts',
952
+ );
953
+
954
+ // Execute all bridges in parallel
955
+ const bridgeResults = await Promise.allSettled(
956
+ bridgePlans.map((plan) =>
957
+ this.executeInventoryMovement(
958
+ plan.chain,
959
+ destination,
960
+ plan.targetOutput,
961
+ plan.maxSourceInput,
962
+ intent,
963
+ route.externalBridge,
964
+ ),
965
+ ),
966
+ );
967
+
968
+ // Process results
969
+ let successCount = 0;
970
+ let totalBridged = 0n;
971
+ const failedErrors: string[] = [];
972
+
973
+ for (let i = 0; i < bridgeResults.length; i++) {
974
+ const result = bridgeResults[i];
975
+ const plan = bridgePlans[i];
976
+
977
+ if (result.status === 'fulfilled' && result.value.success) {
978
+ successCount++;
979
+ totalBridged += plan.targetOutput;
980
+ this.logger.info(
981
+ {
982
+ sourceChain: plan.chain,
983
+ amount: plan.targetOutput.toString(),
984
+ txHash: result.value.txHash,
985
+ },
986
+ 'Inventory movement succeeded',
987
+ );
988
+ } else {
989
+ const error =
990
+ result.status === 'rejected'
991
+ ? result.reason?.message
992
+ : result.value.error;
993
+ if (error) {
994
+ failedErrors.push(`${plan.chain}: ${error}`);
995
+ }
996
+ this.logger.warn(
997
+ {
998
+ sourceChain: plan.chain,
999
+ amount: plan.targetOutput.toString(),
1000
+ error,
1001
+ },
1002
+ 'Inventory movement failed',
1003
+ );
1004
+ }
1005
+ }
1006
+
1007
+ if (successCount === 0) {
1008
+ const errorDetails =
1009
+ failedErrors.length > 0 ? ` (${failedErrors.join('; ')})` : '';
1010
+ return {
1011
+ route,
1012
+ success: false,
1013
+ error: `All inventory movements failed${errorDetails}`,
1014
+ };
1015
+ }
1016
+
1017
+ this.logger.info(
1018
+ {
1019
+ targetChain: destination,
1020
+ successCount,
1021
+ totalBridged: totalBridged.toString(),
1022
+ targetAmount: requestedLocalAmount.toString(),
1023
+ targetAmountCanonical: amount.toString(),
1024
+ intentId: intent.id,
1025
+ },
1026
+ 'Parallel inventory movements completed, transferRemote will execute after bridges complete',
1027
+ );
1028
+
1029
+ return { route, success: true };
840
1030
  }
841
1031
 
842
1032
  /**
@@ -852,12 +1042,11 @@ export class InventoryRebalancer implements IInventoryRebalancer {
852
1042
  *
853
1043
  * @param route - The transfer route (swapped direction)
854
1044
  * @param intent - The rebalance intent being executed
855
- * @param gasQuote - Pre-calculated gas quote from calculateTransferCosts
856
1045
  */
857
1046
  private async executeTransferRemote(
858
1047
  route: InventoryRoute,
859
1048
  intent: RebalanceIntent,
860
- gasQuote: InterchainGasQuote,
1049
+ fulfilledCanonicalAmount: bigint,
861
1050
  ): Promise<InventoryExecutionResult> {
862
1051
  const { origin, destination, amount } = route;
863
1052
 
@@ -869,52 +1058,16 @@ export class InventoryRebalancer implements IInventoryRebalancer {
869
1058
  const destinationDomain = this.multiProvider.getDomainId(destination);
870
1059
 
871
1060
  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',
882
- );
883
-
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,
1061
+ { origin, destination, amount: amount.toString() },
1062
+ 'Building transferRemote transactions for exact execution amount',
895
1063
  );
896
1064
 
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
1065
  const originTokenAmount = originToken.amount(amount);
911
1066
  const transferTxs = await this.warpCore.getTransferRemoteTxs({
912
1067
  originTokenAmount,
913
1068
  destination,
914
1069
  sender: this.getInventorySignerAddress(origin),
915
1070
  recipient: this.getInventorySignerAddress(destination),
916
- interchainFee,
917
- tokenFeeQuote,
918
1071
  });
919
1072
  assert(
920
1073
  transferTxs.length > 0,
@@ -974,7 +1127,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
974
1127
  intentId: intent.id,
975
1128
  origin: this.multiProvider.getDomainId(origin),
976
1129
  destination: destinationDomain,
977
- amount,
1130
+ amount: fulfilledCanonicalAmount,
978
1131
  type: 'inventory_deposit',
979
1132
  txHash: transferTxHash,
980
1133
  messageId,
@@ -1129,40 +1282,30 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1129
1282
  }
1130
1283
 
1131
1284
  /**
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).
1285
+ * Calculate the bridge capacity from a source chain in destination-local units.
1286
+ * Uses LiFi quotes to conservatively estimate the destination output available
1287
+ * from the source chain's current local inventory.
1135
1288
  *
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)
1289
+ * For native-token sources, gas is reserved from the source inventory and the
1290
+ * output capacity is re-quoted from the remaining source input.
1147
1291
  */
1148
- private async calculateMaxViableBridgeAmount(
1292
+ private async calculateBridgeCapacity(
1149
1293
  sourceChain: ChainName,
1150
1294
  targetChain: ChainName,
1151
1295
  rawInventory: bigint,
1152
1296
  externalBridgeType: ExternalBridgeType,
1153
- ): Promise<bigint> {
1297
+ ): Promise<BridgeCapacity> {
1154
1298
  const sourceToken = this.getTokenForChain(sourceChain);
1155
1299
  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
- }
1300
+ assert(sourceToken, `No token found for source chain: ${sourceChain}`);
1301
+ assert(targetToken, `No token found for target chain: ${targetChain}`);
1163
1302
 
1164
1303
  // Convert HypNative token addresses to the external bridge's native token representation
1165
- const fromTokenAddress = this.getNativeTokenAddress(externalBridgeType);
1304
+ const fromTokenAddress = getExternalBridgeTokenAddress(
1305
+ sourceToken,
1306
+ externalBridgeType,
1307
+ this.getNativeTokenAddress.bind(this),
1308
+ );
1166
1309
  const toTokenAddress = getExternalBridgeTokenAddress(
1167
1310
  targetToken,
1168
1311
  externalBridgeType,
@@ -1174,7 +1317,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1174
1317
 
1175
1318
  try {
1176
1319
  const externalBridge = this.getExternalBridge(externalBridgeType);
1177
- const quote = await externalBridge.quote({
1320
+ const initialQuote = await externalBridge.quote({
1178
1321
  fromChain: sourceChainId,
1179
1322
  toChain: targetChainId,
1180
1323
  fromToken: fromTokenAddress,
@@ -1184,48 +1327,58 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1184
1327
  toAddress: this.getInventorySignerAddress(targetChain),
1185
1328
  });
1186
1329
 
1187
- // Apply 20x multiplier on quoted gas (LiFi underestimates by ~14x)
1188
- const estimatedGas = quote.gasCosts * GAS_COST_MULTIPLIER;
1330
+ let maxSourceInput = rawInventory;
1331
+ let outputQuote = initialQuote;
1189
1332
 
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
- }
1333
+ if (isNativeTokenStandard(sourceToken.standard)) {
1334
+ const estimatedGas = initialQuote.gasCosts * GAS_COST_MULTIPLIER;
1335
+ const maxGasThreshold = rawInventory / MAX_GAS_PERCENT_THRESHOLD;
1336
+ if (estimatedGas > maxGasThreshold) {
1337
+ this.logger.info(
1338
+ {
1339
+ sourceChain,
1340
+ targetChain,
1341
+ rawInventory: rawInventory.toString(),
1342
+ quotedGas: initialQuote.gasCosts.toString(),
1343
+ estimatedGas: estimatedGas.toString(),
1344
+ maxGasThreshold: maxGasThreshold.toString(),
1345
+ },
1346
+ 'Bridge not viable - gas cost exceeds 10% of inventory',
1347
+ );
1348
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
1349
+ }
1209
1350
 
1210
- // Max viable = inventory minus estimated gas
1211
- const maxViable = rawInventory - estimatedGas;
1351
+ maxSourceInput = rawInventory - estimatedGas;
1352
+ if (maxSourceInput <= 0n) {
1353
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
1354
+ }
1355
+
1356
+ outputQuote = await externalBridge.quote({
1357
+ fromChain: sourceChainId,
1358
+ toChain: targetChainId,
1359
+ fromToken: fromTokenAddress,
1360
+ toToken: toTokenAddress,
1361
+ fromAmount: maxSourceInput,
1362
+ fromAddress: this.getInventorySignerAddress(sourceChain),
1363
+ toAddress: this.getInventorySignerAddress(targetChain),
1364
+ });
1365
+ }
1212
1366
 
1213
1367
  this.logger.info(
1214
1368
  {
1215
1369
  sourceChain,
1216
1370
  targetChain,
1217
1371
  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),
1372
+ maxSourceInput: maxSourceInput.toString(),
1373
+ maxTargetOutput: outputQuote.toAmountMin.toString(),
1224
1374
  },
1225
- 'Calculated max viable bridge amount',
1375
+ 'Calculated bridge capacity',
1226
1376
  );
1227
1377
 
1228
- return maxViable;
1378
+ return {
1379
+ maxSourceInput,
1380
+ maxTargetOutput: outputQuote.toAmountMin,
1381
+ };
1229
1382
  } catch (error) {
1230
1383
  this.logger.warn(
1231
1384
  {
@@ -1233,21 +1386,22 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1233
1386
  targetChain,
1234
1387
  error: (error as Error).message,
1235
1388
  },
1236
- 'Failed to calculate max viable bridge amount, skipping chain',
1389
+ 'Failed to calculate bridge capacity, skipping chain',
1237
1390
  );
1238
- return 0n;
1391
+ return { maxSourceInput: 0n, maxTargetOutput: 0n };
1239
1392
  }
1240
1393
  }
1241
1394
 
1242
1395
  /**
1243
1396
  * Execute inventory movement from source chain to target chain via LiFi bridge.
1244
1397
  *
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.
1398
+ * Uses reverse quotes (`toAmount`) so plans are expressed in target-chain local
1399
+ * units and source-local spend is discovered by the bridge quote.
1247
1400
  *
1248
1401
  * @param sourceChain - Chain to move inventory from
1249
1402
  * @param targetChain - Chain to move inventory to (origin chain for rebalancing)
1250
- * @param amount - Pre-validated amount to bridge (gas already accounted for)
1403
+ * @param targetOutputAmount - Destination-local amount to receive
1404
+ * @param maxSourceInput - Maximum source-local amount available for this plan
1251
1405
  * @param intent - Rebalance intent for tracking
1252
1406
  * @param externalBridgeType - External bridge type to use
1253
1407
  * @returns Result with success status and optional txHash/error
@@ -1255,7 +1409,8 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1255
1409
  private async executeInventoryMovement(
1256
1410
  sourceChain: ChainName,
1257
1411
  targetChain: ChainName,
1258
- amount: bigint,
1412
+ targetOutputAmount: bigint,
1413
+ maxSourceInput: bigint,
1259
1414
  intent: RebalanceIntent,
1260
1415
  externalBridgeType: ExternalBridgeType,
1261
1416
  ): Promise<{ success: boolean; txHash?: string; error?: string }> {
@@ -1304,44 +1459,6 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1304
1459
  'Resolved token addresses for LiFi bridge',
1305
1460
  );
1306
1461
 
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;
1323
-
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;
1328
-
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
- }
1344
-
1345
1462
  try {
1346
1463
  const externalBridge = this.getExternalBridge(externalBridgeType);
1347
1464
  const quote = await externalBridge.quote({
@@ -1349,12 +1466,18 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1349
1466
  toChain: targetChainId,
1350
1467
  fromToken: fromTokenAddress,
1351
1468
  toToken: toTokenAddress,
1352
- fromAmount: effectiveAmount,
1469
+ toAmount: targetOutputAmount,
1353
1470
  fromAddress: this.getInventorySignerAddress(sourceChain),
1354
1471
  toAddress: this.getInventorySignerAddress(targetChain),
1355
1472
  });
1356
1473
 
1357
1474
  const inputRequired = quote.fromAmount;
1475
+ if (inputRequired > maxSourceInput) {
1476
+ return {
1477
+ success: false,
1478
+ error: `Bridge input ${inputRequired} exceeded planned source capacity ${maxSourceInput}`,
1479
+ };
1480
+ }
1358
1481
 
1359
1482
  this.logger.info(
1360
1483
  {
@@ -1362,19 +1485,27 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1362
1485
  targetChain,
1363
1486
  sourceChainId,
1364
1487
  targetChainId,
1365
- preValidatedAmount: amount.toString(),
1366
- preValidatedAmountEth: (Number(amount) / 1e18).toFixed(6),
1367
- effectiveAmount: effectiveAmount.toString(),
1368
- effectiveAmountEth: (Number(effectiveAmount) / 1e18).toFixed(6),
1488
+ requestedTargetOutput: targetOutputAmount.toString(),
1489
+ requestedTargetOutputFormatted: this.formatLocalAmount(
1490
+ targetOutputAmount,
1491
+ targetToken,
1492
+ ),
1369
1493
  inputRequired: inputRequired.toString(),
1494
+ inputRequiredFormatted: this.formatLocalAmount(
1495
+ inputRequired,
1496
+ sourceToken,
1497
+ ),
1370
1498
  expectedOutput: quote.toAmount.toString(),
1371
- expectedOutputEth: (Number(quote.toAmount) / 1e18).toFixed(6),
1499
+ expectedOutputMin: quote.toAmountMin.toString(),
1500
+ expectedOutputFormatted: this.formatLocalAmount(
1501
+ quote.toAmount,
1502
+ targetToken,
1503
+ ),
1372
1504
  gasCosts: quote.gasCosts.toString(),
1373
1505
  feeCosts: quote.feeCosts.toString(),
1374
1506
  intentId: intent.id,
1375
- adjustedForMinViable: effectiveAmount > amount,
1376
1507
  },
1377
- 'Executing inventory movement via LiFi with pre-validated amount',
1508
+ 'Executing inventory movement via LiFi reverse quote',
1378
1509
  );
1379
1510
 
1380
1511
  this.logger.debug(
@@ -1417,6 +1548,8 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1417
1548
  'Inventory movement transaction executed',
1418
1549
  );
1419
1550
 
1551
+ // Keep bridge consumption in source-local units; intent fulfillment only
1552
+ // advances from canonical inventory_deposit amounts after transferRemote.
1420
1553
  await this.actionTracker.createRebalanceAction({
1421
1554
  intentId: intent.id,
1422
1555
  origin: this.multiProvider.getDomainId(sourceChain),
@@ -1446,7 +1579,7 @@ export class InventoryRebalancer implements IInventoryRebalancer {
1446
1579
  {
1447
1580
  sourceChain,
1448
1581
  targetChain,
1449
- amount: amount.toString(),
1582
+ amount: targetOutputAmount.toString(),
1450
1583
  intentId: intent.id,
1451
1584
  error: (error as Error).message,
1452
1585
  },