@hyperlane-xyz/rebalancer 27.2.12 → 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
@@ -154,6 +154,9 @@ describe('InventoryRebalancer E2E', () => {
154
154
  }),
155
155
  },
156
156
  findToken: Sinon.stub().returns(null),
157
+ getMaxTransferAmount: Sinon.stub().callsFake(
158
+ async ({ balance }) => balance,
159
+ ),
157
160
  getTransferRemoteTxs: Sinon.stub().resolves([
158
161
  {
159
162
  category: 'transfer',
@@ -277,6 +280,21 @@ describe('InventoryRebalancer E2E', () => {
277
280
  return intent;
278
281
  }
279
282
 
283
+ function mockSuccessfulBridge(fromAmount: bigint, toAmount: bigint): void {
284
+ bridge.quote.resolves(
285
+ createMockBridgeQuote({
286
+ fromAmount,
287
+ toAmount,
288
+ toAmountMin: toAmount,
289
+ }),
290
+ );
291
+ bridge.execute.resolves({
292
+ txHash: '0xBridgeTxHash',
293
+ fromChain: 42161,
294
+ toChain: 1399811149,
295
+ });
296
+ }
297
+
280
298
  describe('Basic Inventory Rebalance (Sufficient Inventory)', () => {
281
299
  // NOTE: Strategy route is arbitrum (surplus) → solana (deficit)
282
300
  // But execution calls transferRemote FROM solana TO arbitrum (swapped direction)
@@ -396,11 +414,39 @@ describe('InventoryRebalancer E2E', () => {
396
414
  const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
397
415
  expect(actionParams.txHash).to.equal('0xSolanaTxHash');
398
416
  });
417
+
418
+ it('denormalizes inventory execution amounts but records canonical deposit amount', async () => {
419
+ const route = createTestRoute({ amount: 1_000_000n });
420
+ createTestIntent({ amount: 1_000_000n });
421
+ warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
422
+ numerator: 1n,
423
+ denominator: 1_000_000_000_000n,
424
+ };
425
+
426
+ inventoryRebalancer.setInventoryBalances({
427
+ [SOLANA_CHAIN]: 1_000_000_000_000_000_000n,
428
+ [ARBITRUM_CHAIN]: 0n,
429
+ });
430
+
431
+ const results = await inventoryRebalancer.rebalance([route]);
432
+
433
+ expect(results).to.have.lengthOf(1);
434
+ expect(results[0].success).to.be.true;
435
+
436
+ const txParams = warpCore.getTransferRemoteTxs.firstCall.args[0];
437
+ expect(txParams.originTokenAmount.amount).to.equal(
438
+ 1_000_000_000_000_000_000n,
439
+ );
440
+
441
+ const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
442
+ expect(actionParams.amount).to.equal(1_000_000n);
443
+ });
399
444
  });
400
445
 
401
446
  describe('Partial Fulfillment (Insufficient Inventory)', () => {
402
- // Partial transfers happen when maxTransferable >= minViableTransfer
403
- // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so partial always viable
447
+ // Partial transfers happen when maxTransferable >= minViableTransfer.
448
+ // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
449
+ // positive fee-aware maxTransferable remains viable.
404
450
  const PARTIAL_AMOUNT = BigInt(5e15); // 0.005 ETH - above threshold
405
451
  const FULL_AMOUNT = BigInt(1e16); // 0.01 ETH
406
452
 
@@ -433,6 +479,65 @@ describe('InventoryRebalancer E2E', () => {
433
479
  expect(actionParams.amount).to.equal(PARTIAL_AMOUNT);
434
480
  });
435
481
 
482
+ it('uses fee-aware maxTransferable for non-native token fees', async () => {
483
+ const requestedAmount = 19998000000n;
484
+ const availableInventory = 102466n;
485
+ const safeTransferAmount = 102400n;
486
+
487
+ warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
488
+
489
+ const route = createTestRoute({ amount: requestedAmount });
490
+ createTestIntent({ amount: requestedAmount });
491
+
492
+ inventoryRebalancer.setInventoryBalances({
493
+ [SOLANA_CHAIN]: availableInventory,
494
+ [ARBITRUM_CHAIN]: 0n,
495
+ });
496
+
497
+ const results = await inventoryRebalancer.rebalance([route]);
498
+
499
+ expect(results).to.have.lengthOf(1);
500
+ expect(results[0].success).to.be.true;
501
+ expect(warpCore.getMaxTransferAmount.calledOnce).to.be.true;
502
+
503
+ const maxTransferArgs = warpCore.getMaxTransferAmount.firstCall.args[0];
504
+ expect(maxTransferArgs.balance.amount).to.equal(availableInventory);
505
+ expect(maxTransferArgs.destination).to.equal(ARBITRUM_CHAIN);
506
+
507
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
508
+ expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
509
+ expect(txParams).to.not.have.property('interchainFee');
510
+ expect(txParams).to.not.have.property('tokenFeeQuote');
511
+
512
+ const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
513
+ expect(actionParams.amount).to.equal(safeTransferAmount);
514
+ });
515
+
516
+ it('downgrades full non-native transfers to partial when fee headroom is missing', async () => {
517
+ const requestedAmount = 102466n;
518
+ const safeTransferAmount = 102400n;
519
+
520
+ warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
521
+
522
+ const route = createTestRoute({ amount: requestedAmount });
523
+ createTestIntent({ amount: requestedAmount });
524
+
525
+ inventoryRebalancer.setInventoryBalances({
526
+ [SOLANA_CHAIN]: requestedAmount,
527
+ [ARBITRUM_CHAIN]: 0n,
528
+ });
529
+
530
+ const results = await inventoryRebalancer.rebalance([route]);
531
+
532
+ expect(results).to.have.lengthOf(1);
533
+ expect(results[0].success).to.be.true;
534
+ expect(bridge.execute.called).to.be.false;
535
+
536
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
537
+ expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
538
+ expect(txParams.originTokenAmount.amount).to.not.equal(requestedAmount);
539
+ });
540
+
436
541
  it('intent remains in_progress after partial fulfillment', async () => {
437
542
  const route = createTestRoute({ amount: FULL_AMOUNT });
438
543
  createTestIntent({ amount: FULL_AMOUNT });
@@ -450,6 +555,52 @@ describe('InventoryRebalancer E2E', () => {
450
555
  });
451
556
  });
452
557
 
558
+ describe('Fee-Aware Probe Fallback', () => {
559
+ it('bridges when non-native destination inventory is zero', async () => {
560
+ const requestedAmount = 10000000000n;
561
+ const bufferedBridgeAmount = (requestedAmount * 105n) / 100n;
562
+ const route = createTestRoute({ amount: requestedAmount });
563
+ createTestIntent({ amount: requestedAmount });
564
+
565
+ inventoryRebalancer.setInventoryBalances({
566
+ [SOLANA_CHAIN]: 0n,
567
+ [ARBITRUM_CHAIN]: 20000000000n,
568
+ });
569
+ mockSuccessfulBridge(bufferedBridgeAmount, bufferedBridgeAmount);
570
+
571
+ const results = await inventoryRebalancer.rebalance([route]);
572
+
573
+ expect(results).to.have.lengthOf(1);
574
+ expect(results[0].success).to.be.true;
575
+ expect(warpCore.getMaxTransferAmount.called).to.be.false;
576
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
577
+ expect(bridge.execute.calledOnce).to.be.true;
578
+
579
+ const actionParams =
580
+ actionTracker.createRebalanceAction.firstCall.args[0];
581
+ expect(actionParams.type).to.equal('inventory_movement');
582
+ });
583
+
584
+ it('returns failure for unrelated fee-aware probe errors', async () => {
585
+ const route = createTestRoute();
586
+ createTestIntent();
587
+
588
+ inventoryRebalancer.setInventoryBalances({
589
+ [SOLANA_CHAIN]: 1n,
590
+ [ARBITRUM_CHAIN]: 20000000000n,
591
+ });
592
+ warpCore.getMaxTransferAmount.rejects(new Error('RPC down'));
593
+
594
+ const results = await inventoryRebalancer.rebalance([route]);
595
+
596
+ expect(results).to.have.lengthOf(1);
597
+ expect(results[0].success).to.be.false;
598
+ expect(results[0].error).to.include('RPC down');
599
+ expect(bridge.execute.called).to.be.false;
600
+ expect(actionTracker.createRebalanceAction.called).to.be.false;
601
+ });
602
+ });
603
+
453
604
  describe('No Inventory Available', () => {
454
605
  it('returns failure when no inventory on destination chain and no other source available', async () => {
455
606
  const route = createTestRoute();
@@ -626,6 +777,51 @@ describe('InventoryRebalancer E2E', () => {
626
777
  // Verify: No new intent was created (existing was continued)
627
778
  expect(actionTracker.createRebalanceIntent.called).to.be.false;
628
779
  });
780
+
781
+ it('continues existing intent by bridging after recoverable fee-aware probe failure', async () => {
782
+ const existingIntent = createTestIntent({
783
+ id: 'existing-intent',
784
+ status: 'in_progress',
785
+ amount: 10000000000n,
786
+ externalBridge: ExternalBridgeType.LiFi,
787
+ });
788
+ const recoverableProbeError = new Error('Fee probe failed') as Error & {
789
+ cause?: unknown;
790
+ };
791
+ recoverableProbeError.cause = {
792
+ code: 'UNPREDICTABLE_GAS_LIMIT',
793
+ message: 'execution reverted: ERC20: transfer amount exceeds balance',
794
+ };
795
+
796
+ actionTracker.getPartiallyFulfilledInventoryIntents.resolves([
797
+ {
798
+ intent: existingIntent,
799
+ completedAmount: 0n,
800
+ remaining: 10000000000n,
801
+ hasInflightDeposit: false,
802
+ },
803
+ ]);
804
+
805
+ inventoryRebalancer.setInventoryBalances({
806
+ [SOLANA_CHAIN]: 1n,
807
+ [ARBITRUM_CHAIN]: 20000000000n,
808
+ });
809
+ warpCore.getMaxTransferAmount.rejects(recoverableProbeError);
810
+ mockSuccessfulBridge(10500000000n, 10500000000n);
811
+
812
+ const results = await inventoryRebalancer.rebalance([
813
+ createTestRoute({ amount: 5000000000n }),
814
+ ]);
815
+
816
+ expect(results).to.have.lengthOf(1);
817
+ expect(results[0].success).to.be.true;
818
+ expect(bridge.execute.calledOnce).to.be.true;
819
+ expect(actionTracker.createRebalanceIntent.called).to.be.false;
820
+
821
+ const actionParams =
822
+ actionTracker.createRebalanceAction.firstCall.args[0];
823
+ expect(actionParams.type).to.equal('inventory_movement');
824
+ });
629
825
  });
630
826
 
631
827
  describe('Error Handling', () => {
@@ -1457,6 +1653,73 @@ describe('InventoryRebalancer E2E', () => {
1457
1653
  expect(bridge.execute.callCount).to.equal(2);
1458
1654
  });
1459
1655
 
1656
+ it('does not inflate each split bridge plan to minViableTransfer', async () => {
1657
+ const amount = 7_000_000_000_000_000n;
1658
+ const perChainInventory = 6_000_000_000_000_000n;
1659
+ const reservedGas = 1_000_000_000n;
1660
+
1661
+ for (const token of warpCore.tokens) {
1662
+ if (
1663
+ token.chainName === ARBITRUM_CHAIN ||
1664
+ token.chainName === SOLANA_CHAIN ||
1665
+ token.chainName === BASE_CHAIN
1666
+ ) {
1667
+ token.standard = TokenStandard.EvmHypNative;
1668
+ }
1669
+ }
1670
+
1671
+ const route = createTestRoute({ amount });
1672
+ createTestIntent({ amount });
1673
+
1674
+ inventoryRebalancer.setInventoryBalances({
1675
+ [SOLANA_CHAIN]: 0n,
1676
+ [ARBITRUM_CHAIN]: perChainInventory,
1677
+ [BASE_CHAIN]: perChainInventory,
1678
+ });
1679
+
1680
+ bridge.quote.callsFake(async (params: any) =>
1681
+ createMockBridgeQuote({
1682
+ fromAmount: params.fromAmount ?? params.toAmount,
1683
+ toAmount: params.toAmount ?? params.fromAmount,
1684
+ toAmountMin: params.toAmount ?? params.fromAmount,
1685
+ requestParams:
1686
+ params.toAmount !== undefined
1687
+ ? { ...params, toAmount: params.toAmount }
1688
+ : { ...params, fromAmount: params.fromAmount },
1689
+ }),
1690
+ );
1691
+ bridge.execute.resolves({
1692
+ txHash: '0xBridgeTxHash',
1693
+ fromChain: 42161,
1694
+ toChain: 1399811149,
1695
+ });
1696
+
1697
+ const results = await inventoryRebalancer.rebalance([route]);
1698
+
1699
+ expect(results).to.have.lengthOf(1);
1700
+ expect(results[0].success).to.be.true;
1701
+ expect(bridge.execute.callCount).to.equal(2);
1702
+
1703
+ const reverseQuoteRequests = bridge.quote
1704
+ .getCalls()
1705
+ .map((call) => call.args[0])
1706
+ .filter((params) => params.toAmount !== undefined);
1707
+ expect(reverseQuoteRequests).to.have.lengthOf(2);
1708
+
1709
+ const requestedOutputs = reverseQuoteRequests
1710
+ .map((params) => params.toAmount as bigint)
1711
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1712
+ const maxPerSourceOutput = perChainInventory - reservedGas;
1713
+ const totalAvailableCapacity = maxPerSourceOutput * 2n;
1714
+
1715
+ expect(requestedOutputs.every((output) => output <= maxPerSourceOutput))
1716
+ .to.be.true;
1717
+ expect(requestedOutputs[0] < maxPerSourceOutput).to.be.true;
1718
+ expect(requestedOutputs[1]).to.equal(maxPerSourceOutput);
1719
+ expect(requestedOutputs[0] + requestedOutputs[1] < totalAvailableCapacity)
1720
+ .to.be.true;
1721
+ });
1722
+
1460
1723
  it('applies 5% buffer to total bridge amount', async () => {
1461
1724
  // Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
1462
1725
  const amount = BigInt(1e18); // 1 ETH
@@ -1471,14 +1734,19 @@ describe('InventoryRebalancer E2E', () => {
1471
1734
  [ARBITRUM_CHAIN]: availableInventory,
1472
1735
  });
1473
1736
 
1474
- // Capture the quote amount from executeInventoryMovement
1475
- // (calculateMaxViableBridgeAmount doesn't quote for ERC20 tokens)
1476
- let quotedFromAmount: bigint | undefined;
1737
+ let quotedTargetOutput: bigint | undefined;
1477
1738
  bridge.quote.callsFake(async (params: any) => {
1478
- quotedFromAmount = params.fromAmount;
1739
+ if (params.toAmount !== undefined) {
1740
+ quotedTargetOutput = params.toAmount;
1741
+ }
1479
1742
  return createMockBridgeQuote({
1480
1743
  fromAmount: params.fromAmount ?? params.toAmount,
1481
- toAmount: params.fromAmount ?? params.toAmount,
1744
+ toAmount: params.toAmount ?? params.fromAmount,
1745
+ toAmountMin: params.toAmount ?? params.fromAmount,
1746
+ requestParams:
1747
+ params.toAmount !== undefined
1748
+ ? { ...params, toAmount: params.toAmount }
1749
+ : { ...params, fromAmount: params.fromAmount },
1482
1750
  });
1483
1751
  });
1484
1752
  bridge.execute.resolves({
@@ -1490,10 +1758,155 @@ describe('InventoryRebalancer E2E', () => {
1490
1758
  await inventoryRebalancer.rebalance([route]);
1491
1759
 
1492
1760
  // Verify: 5% buffer applied (1 ETH * 1.05 = 1.05 ETH)
1493
- // The bridge plan uses pre-validated amounts (for ERC20, full inventory available)
1494
- // But the target is (amount * 105%), so if source has >= target, we bridge exactly target
1495
1761
  const expectedWithBuffer = (amount * 105n) / 100n;
1496
- expect(quotedFromAmount).to.equal(expectedWithBuffer);
1762
+ expect(quotedTargetOutput).to.equal(expectedWithBuffer);
1763
+ });
1764
+
1765
+ it('bridges only the mixed-decimal shortfall after dust partial skip', async () => {
1766
+ const canonicalAmount = 1n;
1767
+ const destinationDust = 1_000_000_000_000n - 1n;
1768
+ const sourceInventory = 1_000_000_000_000n;
1769
+ warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
1770
+ numerator: 1n,
1771
+ denominator: 1_000_000_000_000n,
1772
+ };
1773
+
1774
+ const route = createTestRoute({ amount: canonicalAmount });
1775
+ createTestIntent({ amount: canonicalAmount });
1776
+
1777
+ inventoryRebalancer.setInventoryBalances({
1778
+ [SOLANA_CHAIN]: destinationDust,
1779
+ [ARBITRUM_CHAIN]: sourceInventory,
1780
+ });
1781
+
1782
+ let reverseQuoteTargetOutput: bigint | undefined;
1783
+ bridge.quote.callsFake(async (params: any) => {
1784
+ if (params.toAmount !== undefined) {
1785
+ reverseQuoteTargetOutput = params.toAmount;
1786
+ }
1787
+ return createMockBridgeQuote({
1788
+ fromAmount: params.fromAmount ?? params.toAmount,
1789
+ toAmount: params.toAmount ?? params.fromAmount,
1790
+ toAmountMin: params.toAmount ?? params.fromAmount,
1791
+ requestParams:
1792
+ params.toAmount !== undefined
1793
+ ? { ...params, toAmount: params.toAmount }
1794
+ : { ...params, fromAmount: params.fromAmount },
1795
+ });
1796
+ });
1797
+ bridge.execute.resolves({
1798
+ txHash: '0xBridgeTxHash',
1799
+ fromChain: 42161,
1800
+ toChain: 1399811149,
1801
+ });
1802
+
1803
+ const results = await inventoryRebalancer.rebalance([route]);
1804
+
1805
+ expect(results).to.have.lengthOf(1);
1806
+ expect(results[0].success).to.be.true;
1807
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
1808
+ expect(bridge.execute.calledOnce).to.be.true;
1809
+ expect(reverseQuoteTargetOutput).to.equal(1n);
1810
+ });
1811
+
1812
+ it('plans LiFi target output in destination-local units for mixed-decimal routes', async () => {
1813
+ const canonicalAmount = 1_000_000n;
1814
+ const availableInventory = BigInt(2e18);
1815
+ warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
1816
+ numerator: 1n,
1817
+ denominator: 1_000_000_000_000n,
1818
+ };
1819
+
1820
+ const route = createTestRoute({ amount: canonicalAmount });
1821
+ createTestIntent({ amount: canonicalAmount });
1822
+
1823
+ inventoryRebalancer.setInventoryBalances({
1824
+ [SOLANA_CHAIN]: 0n,
1825
+ [ARBITRUM_CHAIN]: availableInventory,
1826
+ });
1827
+
1828
+ let quotedTargetOutput: bigint | undefined;
1829
+ bridge.quote.callsFake(async (params: any) => {
1830
+ if (params.toAmount !== undefined) {
1831
+ quotedTargetOutput = params.toAmount;
1832
+ }
1833
+ return createMockBridgeQuote({
1834
+ fromAmount: params.fromAmount ?? params.toAmount,
1835
+ toAmount: params.toAmount ?? params.fromAmount,
1836
+ toAmountMin: params.toAmount ?? params.fromAmount,
1837
+ requestParams:
1838
+ params.toAmount !== undefined
1839
+ ? { ...params, toAmount: params.toAmount }
1840
+ : { ...params, fromAmount: params.fromAmount },
1841
+ });
1842
+ });
1843
+ bridge.execute.resolves({
1844
+ txHash: '0xBridgeTxHash',
1845
+ fromChain: 42161,
1846
+ toChain: 1399811149,
1847
+ });
1848
+
1849
+ await inventoryRebalancer.rebalance([route]);
1850
+
1851
+ expect(quotedTargetOutput).to.equal(1_050_000_000_000_000_000n);
1852
+ });
1853
+
1854
+ it('fails bridge execution when reverse quote exceeds planned source capacity', async () => {
1855
+ const amount = BigInt(1e18);
1856
+ const sourceInventory = BigInt(2e18);
1857
+ const targetWithBuffer = (amount * 105n) / 100n;
1858
+
1859
+ const route = createTestRoute({ amount });
1860
+ createTestIntent({ amount });
1861
+
1862
+ inventoryRebalancer.setInventoryBalances({
1863
+ [SOLANA_CHAIN]: 0n,
1864
+ [ARBITRUM_CHAIN]: sourceInventory,
1865
+ });
1866
+
1867
+ bridge.quote
1868
+ .onFirstCall()
1869
+ .resolves(
1870
+ createMockBridgeQuote({
1871
+ fromAmount: sourceInventory,
1872
+ toAmount: sourceInventory,
1873
+ toAmountMin: sourceInventory,
1874
+ requestParams: {
1875
+ fromChain: 42161,
1876
+ toChain: 1399811149,
1877
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1878
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1879
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1880
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1881
+ fromAmount: sourceInventory,
1882
+ },
1883
+ }),
1884
+ )
1885
+ .onSecondCall()
1886
+ .resolves(
1887
+ createMockBridgeQuote({
1888
+ fromAmount: sourceInventory + 1n,
1889
+ toAmount: targetWithBuffer,
1890
+ toAmountMin: targetWithBuffer,
1891
+ requestParams: {
1892
+ fromChain: 42161,
1893
+ toChain: 1399811149,
1894
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1895
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1896
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1897
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1898
+ toAmount: targetWithBuffer,
1899
+ },
1900
+ }),
1901
+ );
1902
+
1903
+ const results = await inventoryRebalancer.rebalance([route]);
1904
+
1905
+ expect(results).to.have.lengthOf(1);
1906
+ expect(results[0].success).to.be.false;
1907
+ expect(results[0].error).to.include('All inventory movements failed');
1908
+ expect(results[0].error).to.include('exceeded planned source capacity');
1909
+ expect(bridge.execute.called).to.be.false;
1497
1910
  });
1498
1911
 
1499
1912
  it('continues when some bridges fail', async () => {
@@ -1721,14 +2134,36 @@ describe('InventoryRebalancer E2E', () => {
1721
2134
  });
1722
2135
 
1723
2136
  // Quote with high gas costs (but for ERC20, this shouldn't trigger viability check)
1724
- bridge.quote.resolves(
1725
- createMockBridgeQuote({
1726
- fromAmount: BigInt(1.05e18),
1727
- toAmount: BigInt(1e18),
1728
- gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
1729
- feeCosts: 0n,
1730
- }),
1731
- );
2137
+ bridge.quote
2138
+ .onFirstCall()
2139
+ .resolves(
2140
+ createMockBridgeQuote({
2141
+ fromAmount: tokenBalance,
2142
+ toAmount: tokenBalance,
2143
+ toAmountMin: tokenBalance,
2144
+ gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
2145
+ feeCosts: 0n,
2146
+ }),
2147
+ )
2148
+ .onSecondCall()
2149
+ .resolves(
2150
+ createMockBridgeQuote({
2151
+ fromAmount: tokenBalance,
2152
+ toAmount: tokenBalance,
2153
+ toAmountMin: tokenBalance,
2154
+ gasCosts: BigInt(1e18),
2155
+ feeCosts: 0n,
2156
+ requestParams: {
2157
+ fromChain: 42161,
2158
+ toChain: 1399811149,
2159
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2160
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2161
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2162
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2163
+ toAmount: tokenBalance,
2164
+ },
2165
+ }),
2166
+ );
1732
2167
 
1733
2168
  bridge.execute.resolves({
1734
2169
  txHash: '0xERC20BridgeTxHash',
@@ -1742,6 +2177,7 @@ describe('InventoryRebalancer E2E', () => {
1742
2177
  expect(results).to.have.lengthOf(1);
1743
2178
  expect(results[0].success).to.be.true;
1744
2179
  expect(bridge.execute.calledOnce).to.be.true;
2180
+ expect(bridge.quote.getCall(1)?.args[0].toAmount).to.equal(tokenBalance);
1745
2181
  });
1746
2182
 
1747
2183
  it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
@@ -1777,14 +2213,47 @@ describe('InventoryRebalancer E2E', () => {
1777
2213
  // maxViable = 1 ETH - 0.02 ETH = 0.98 ETH
1778
2214
  // 10% threshold = 0.1 ETH, estimatedGas (0.02) < threshold → viable
1779
2215
  const gasCosts = BigInt(0.001e18); // 0.001 ETH
1780
- bridge.quote.resolves(
1781
- createMockBridgeQuote({
1782
- fromAmount: rawBalance,
1783
- toAmount: rawBalance - BigInt(1e15),
1784
- gasCosts,
1785
- feeCosts: 0n,
1786
- }),
1787
- );
2216
+ const maxViable = rawBalance - gasCosts * 20n;
2217
+ bridge.quote
2218
+ .onFirstCall()
2219
+ .resolves(
2220
+ createMockBridgeQuote({
2221
+ fromAmount: rawBalance,
2222
+ toAmount: rawBalance - BigInt(1e15),
2223
+ toAmountMin: rawBalance - BigInt(1e15),
2224
+ gasCosts,
2225
+ feeCosts: 0n,
2226
+ }),
2227
+ )
2228
+ .onSecondCall()
2229
+ .resolves(
2230
+ createMockBridgeQuote({
2231
+ fromAmount: maxViable,
2232
+ toAmount: maxViable - BigInt(1e15),
2233
+ toAmountMin: maxViable - BigInt(1e15),
2234
+ gasCosts,
2235
+ feeCosts: 0n,
2236
+ }),
2237
+ )
2238
+ .onThirdCall()
2239
+ .resolves(
2240
+ createMockBridgeQuote({
2241
+ fromAmount: BigInt(0.525e18),
2242
+ toAmount: BigInt(0.525e18),
2243
+ toAmountMin: BigInt(0.525e18),
2244
+ gasCosts,
2245
+ feeCosts: 0n,
2246
+ requestParams: {
2247
+ fromChain: 42161,
2248
+ toChain: 1399811149,
2249
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2250
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2251
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2252
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2253
+ toAmount: BigInt(0.525e18),
2254
+ },
2255
+ }),
2256
+ );
1788
2257
 
1789
2258
  bridge.execute.resolves({
1790
2259
  txHash: '0xSuccessBridgeTxHash',
@@ -1799,18 +2268,14 @@ describe('InventoryRebalancer E2E', () => {
1799
2268
  expect(results[0].success).to.be.true;
1800
2269
  expect(bridge.execute.calledOnce).to.be.true;
1801
2270
 
1802
- // Verify: The quoted fromAmount should be the target (since maxViable > target)
1803
- // For the execution quote (second quote call):
1804
- // targetWithBuffer = (0.5 ETH) * 1.05 = 0.525 ETH (for non-inventory execution, costs are 0)
1805
- const executionQuoteCall = bridge.quote
1806
- .getCalls()
1807
- .find(
1808
- (call: any) =>
1809
- call.args[0].fromAmount !== undefined &&
1810
- call.args[0].fromAmount !== rawBalance,
1811
- );
1812
- // Since maxViable (0.98 ETH) > targetWithBuffer (0.525 ETH), we bridge exactly targetWithBuffer
1813
- expect(executionQuoteCall).to.exist;
2271
+ expect(bridge.quote.getCall(1)?.args[0].fromAmount).to.equal(maxViable);
2272
+ const executionTargetOutput = bridge.quote.getCall(2)?.args[0].toAmount;
2273
+ expect(executionTargetOutput).to.be.a('bigint');
2274
+ if (executionTargetOutput === undefined) {
2275
+ throw new Error('Expected reverse quote to set toAmount');
2276
+ }
2277
+ expect(executionTargetOutput > amount).to.be.true;
2278
+ expect(executionTargetOutput <= maxViable).to.be.true;
1814
2279
  });
1815
2280
 
1816
2281
  it('handles quote failures gracefully by skipping the source chain', async () => {