@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
@@ -1,6 +1,7 @@
1
1
  import chai, { expect } from 'chai';
2
2
  import chaiAsPromised from 'chai-as-promised';
3
3
  import { Wallet } from 'ethers';
4
+ import type { Logger } from 'pino';
4
5
  import { pino } from 'pino';
5
6
  import Sinon, { type SinonStubbedInstance } from 'sinon';
6
7
 
@@ -12,7 +13,7 @@ import {
12
13
  WarpTxCategory,
13
14
  type WarpCore,
14
15
  } from '@hyperlane-xyz/sdk';
15
- import { ProtocolType } from '@hyperlane-xyz/utils';
16
+ import { assert, ProtocolType } from '@hyperlane-xyz/utils';
16
17
 
17
18
  import { ExternalBridgeType } from '../config/types.js';
18
19
  import type { IExternalBridge } from '../interfaces/IExternalBridge.js';
@@ -154,6 +155,9 @@ describe('InventoryRebalancer E2E', () => {
154
155
  }),
155
156
  },
156
157
  findToken: Sinon.stub().returns(null),
158
+ getMaxTransferAmount: Sinon.stub().callsFake(
159
+ async ({ balance }) => balance,
160
+ ),
157
161
  getTransferRemoteTxs: Sinon.stub().resolves([
158
162
  {
159
163
  category: 'transfer',
@@ -277,6 +281,21 @@ describe('InventoryRebalancer E2E', () => {
277
281
  return intent;
278
282
  }
279
283
 
284
+ function mockSuccessfulBridge(fromAmount: bigint, toAmount: bigint): void {
285
+ bridge.quote.resolves(
286
+ createMockBridgeQuote({
287
+ fromAmount,
288
+ toAmount,
289
+ toAmountMin: toAmount,
290
+ }),
291
+ );
292
+ bridge.execute.resolves({
293
+ txHash: '0xBridgeTxHash',
294
+ fromChain: 42161,
295
+ toChain: 1399811149,
296
+ });
297
+ }
298
+
280
299
  describe('Basic Inventory Rebalance (Sufficient Inventory)', () => {
281
300
  // NOTE: Strategy route is arbitrum (surplus) → solana (deficit)
282
301
  // But execution calls transferRemote FROM solana TO arbitrum (swapped direction)
@@ -396,11 +415,39 @@ describe('InventoryRebalancer E2E', () => {
396
415
  const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
397
416
  expect(actionParams.txHash).to.equal('0xSolanaTxHash');
398
417
  });
418
+
419
+ it('denormalizes inventory execution amounts but records canonical deposit amount', async () => {
420
+ const route = createTestRoute({ amount: 1_000_000n });
421
+ createTestIntent({ amount: 1_000_000n });
422
+ warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
423
+ numerator: 1n,
424
+ denominator: 1_000_000_000_000n,
425
+ };
426
+
427
+ inventoryRebalancer.setInventoryBalances({
428
+ [SOLANA_CHAIN]: 1_000_000_000_000_000_000n,
429
+ [ARBITRUM_CHAIN]: 0n,
430
+ });
431
+
432
+ const results = await inventoryRebalancer.rebalance([route]);
433
+
434
+ expect(results).to.have.lengthOf(1);
435
+ expect(results[0].success).to.be.true;
436
+
437
+ const txParams = warpCore.getTransferRemoteTxs.firstCall.args[0];
438
+ expect(txParams.originTokenAmount.amount).to.equal(
439
+ 1_000_000_000_000_000_000n,
440
+ );
441
+
442
+ const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
443
+ expect(actionParams.amount).to.equal(1_000_000n);
444
+ });
399
445
  });
400
446
 
401
447
  describe('Partial Fulfillment (Insufficient Inventory)', () => {
402
- // Partial transfers happen when maxTransferable >= minViableTransfer
403
- // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so partial always viable
448
+ // Partial transfers happen when maxTransferable >= minViableTransfer.
449
+ // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
450
+ // positive fee-aware maxTransferable remains viable.
404
451
  const PARTIAL_AMOUNT = BigInt(5e15); // 0.005 ETH - above threshold
405
452
  const FULL_AMOUNT = BigInt(1e16); // 0.01 ETH
406
453
 
@@ -433,6 +480,65 @@ describe('InventoryRebalancer E2E', () => {
433
480
  expect(actionParams.amount).to.equal(PARTIAL_AMOUNT);
434
481
  });
435
482
 
483
+ it('uses fee-aware maxTransferable for non-native token fees', async () => {
484
+ const requestedAmount = 19998000000n;
485
+ const availableInventory = 102466n;
486
+ const safeTransferAmount = 102400n;
487
+
488
+ warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
489
+
490
+ const route = createTestRoute({ amount: requestedAmount });
491
+ createTestIntent({ amount: requestedAmount });
492
+
493
+ inventoryRebalancer.setInventoryBalances({
494
+ [SOLANA_CHAIN]: availableInventory,
495
+ [ARBITRUM_CHAIN]: 0n,
496
+ });
497
+
498
+ const results = await inventoryRebalancer.rebalance([route]);
499
+
500
+ expect(results).to.have.lengthOf(1);
501
+ expect(results[0].success).to.be.true;
502
+ expect(warpCore.getMaxTransferAmount.calledOnce).to.be.true;
503
+
504
+ const maxTransferArgs = warpCore.getMaxTransferAmount.firstCall.args[0];
505
+ expect(maxTransferArgs.balance.amount).to.equal(availableInventory);
506
+ expect(maxTransferArgs.destination).to.equal(ARBITRUM_CHAIN);
507
+
508
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
509
+ expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
510
+ expect(txParams).to.not.have.property('interchainFee');
511
+ expect(txParams).to.not.have.property('tokenFeeQuote');
512
+
513
+ const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
514
+ expect(actionParams.amount).to.equal(safeTransferAmount);
515
+ });
516
+
517
+ it('downgrades full non-native transfers to partial when fee headroom is missing', async () => {
518
+ const requestedAmount = 102466n;
519
+ const safeTransferAmount = 102400n;
520
+
521
+ warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
522
+
523
+ const route = createTestRoute({ amount: requestedAmount });
524
+ createTestIntent({ amount: requestedAmount });
525
+
526
+ inventoryRebalancer.setInventoryBalances({
527
+ [SOLANA_CHAIN]: requestedAmount,
528
+ [ARBITRUM_CHAIN]: 0n,
529
+ });
530
+
531
+ const results = await inventoryRebalancer.rebalance([route]);
532
+
533
+ expect(results).to.have.lengthOf(1);
534
+ expect(results[0].success).to.be.true;
535
+ expect(bridge.execute.called).to.be.false;
536
+
537
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
538
+ expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
539
+ expect(txParams.originTokenAmount.amount).to.not.equal(requestedAmount);
540
+ });
541
+
436
542
  it('intent remains in_progress after partial fulfillment', async () => {
437
543
  const route = createTestRoute({ amount: FULL_AMOUNT });
438
544
  createTestIntent({ amount: FULL_AMOUNT });
@@ -450,6 +556,52 @@ describe('InventoryRebalancer E2E', () => {
450
556
  });
451
557
  });
452
558
 
559
+ describe('Fee-Aware Probe Fallback', () => {
560
+ it('bridges when non-native destination inventory is zero', async () => {
561
+ const requestedAmount = 10000000000n;
562
+ const bufferedBridgeAmount = (requestedAmount * 105n) / 100n;
563
+ const route = createTestRoute({ amount: requestedAmount });
564
+ createTestIntent({ amount: requestedAmount });
565
+
566
+ inventoryRebalancer.setInventoryBalances({
567
+ [SOLANA_CHAIN]: 0n,
568
+ [ARBITRUM_CHAIN]: 20000000000n,
569
+ });
570
+ mockSuccessfulBridge(bufferedBridgeAmount, bufferedBridgeAmount);
571
+
572
+ const results = await inventoryRebalancer.rebalance([route]);
573
+
574
+ expect(results).to.have.lengthOf(1);
575
+ expect(results[0].success).to.be.true;
576
+ expect(warpCore.getMaxTransferAmount.called).to.be.false;
577
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
578
+ expect(bridge.execute.calledOnce).to.be.true;
579
+
580
+ const actionParams =
581
+ actionTracker.createRebalanceAction.firstCall.args[0];
582
+ expect(actionParams.type).to.equal('inventory_movement');
583
+ });
584
+
585
+ it('returns failure for unrelated fee-aware probe errors', async () => {
586
+ const route = createTestRoute();
587
+ createTestIntent();
588
+
589
+ inventoryRebalancer.setInventoryBalances({
590
+ [SOLANA_CHAIN]: 1n,
591
+ [ARBITRUM_CHAIN]: 20000000000n,
592
+ });
593
+ warpCore.getMaxTransferAmount.rejects(new Error('RPC down'));
594
+
595
+ const results = await inventoryRebalancer.rebalance([route]);
596
+
597
+ expect(results).to.have.lengthOf(1);
598
+ expect(results[0].success).to.be.false;
599
+ expect(results[0].error).to.include('RPC down');
600
+ expect(bridge.execute.called).to.be.false;
601
+ expect(actionTracker.createRebalanceAction.called).to.be.false;
602
+ });
603
+ });
604
+
453
605
  describe('No Inventory Available', () => {
454
606
  it('returns failure when no inventory on destination chain and no other source available', async () => {
455
607
  const route = createTestRoute();
@@ -626,6 +778,51 @@ describe('InventoryRebalancer E2E', () => {
626
778
  // Verify: No new intent was created (existing was continued)
627
779
  expect(actionTracker.createRebalanceIntent.called).to.be.false;
628
780
  });
781
+
782
+ it('continues existing intent by bridging after recoverable fee-aware probe failure', async () => {
783
+ const existingIntent = createTestIntent({
784
+ id: 'existing-intent',
785
+ status: 'in_progress',
786
+ amount: 10000000000n,
787
+ externalBridge: ExternalBridgeType.LiFi,
788
+ });
789
+ const recoverableProbeError = new Error('Fee probe failed') as Error & {
790
+ cause?: unknown;
791
+ };
792
+ recoverableProbeError.cause = {
793
+ code: 'UNPREDICTABLE_GAS_LIMIT',
794
+ message: 'execution reverted: ERC20: transfer amount exceeds balance',
795
+ };
796
+
797
+ actionTracker.getPartiallyFulfilledInventoryIntents.resolves([
798
+ {
799
+ intent: existingIntent,
800
+ completedAmount: 0n,
801
+ remaining: 10000000000n,
802
+ hasInflightDeposit: false,
803
+ },
804
+ ]);
805
+
806
+ inventoryRebalancer.setInventoryBalances({
807
+ [SOLANA_CHAIN]: 1n,
808
+ [ARBITRUM_CHAIN]: 20000000000n,
809
+ });
810
+ warpCore.getMaxTransferAmount.rejects(recoverableProbeError);
811
+ mockSuccessfulBridge(10500000000n, 10500000000n);
812
+
813
+ const results = await inventoryRebalancer.rebalance([
814
+ createTestRoute({ amount: 5000000000n }),
815
+ ]);
816
+
817
+ expect(results).to.have.lengthOf(1);
818
+ expect(results[0].success).to.be.true;
819
+ expect(bridge.execute.calledOnce).to.be.true;
820
+ expect(actionTracker.createRebalanceIntent.called).to.be.false;
821
+
822
+ const actionParams =
823
+ actionTracker.createRebalanceAction.firstCall.args[0];
824
+ expect(actionParams.type).to.equal('inventory_movement');
825
+ });
629
826
  });
630
827
 
631
828
  describe('Error Handling', () => {
@@ -1457,6 +1654,83 @@ describe('InventoryRebalancer E2E', () => {
1457
1654
  expect(bridge.execute.callCount).to.equal(2);
1458
1655
  });
1459
1656
 
1657
+ it('does not inflate each split bridge plan to minViableTransfer', async () => {
1658
+ const amount = 7_000_000_000_000_000n;
1659
+ const perChainInventory = 6_000_000_000_000_000n;
1660
+ const reservedGas = 1_000_000_000n;
1661
+
1662
+ for (const token of warpCore.tokens) {
1663
+ if (
1664
+ token.chainName === ARBITRUM_CHAIN ||
1665
+ token.chainName === SOLANA_CHAIN ||
1666
+ token.chainName === BASE_CHAIN
1667
+ ) {
1668
+ token.standard = TokenStandard.EvmHypNative;
1669
+ }
1670
+ }
1671
+
1672
+ const route = createTestRoute({ amount });
1673
+ createTestIntent({ amount });
1674
+
1675
+ inventoryRebalancer.setInventoryBalances({
1676
+ [SOLANA_CHAIN]: 0n,
1677
+ [ARBITRUM_CHAIN]: perChainInventory,
1678
+ [BASE_CHAIN]: perChainInventory,
1679
+ });
1680
+
1681
+ bridge.quote.callsFake(async (params: any) =>
1682
+ createMockBridgeQuote({
1683
+ fromAmount: params.fromAmount ?? params.toAmount,
1684
+ toAmount: params.toAmount ?? params.fromAmount,
1685
+ toAmountMin: params.toAmount ?? params.fromAmount,
1686
+ requestParams:
1687
+ params.toAmount !== undefined
1688
+ ? { ...params, toAmount: params.toAmount }
1689
+ : { ...params, fromAmount: params.fromAmount },
1690
+ }),
1691
+ );
1692
+ bridge.execute.resolves({
1693
+ txHash: '0xBridgeTxHash',
1694
+ fromChain: 42161,
1695
+ toChain: 1399811149,
1696
+ });
1697
+
1698
+ const results = await inventoryRebalancer.rebalance([route]);
1699
+
1700
+ expect(results).to.have.lengthOf(1);
1701
+ expect(results[0].success).to.be.true;
1702
+ expect(bridge.execute.callCount).to.equal(2);
1703
+
1704
+ const maxPerSourceOutput = perChainInventory - reservedGas;
1705
+ const executionQuoteRequests = bridge.quote
1706
+ .getCalls()
1707
+ .slice(-2)
1708
+ .map((call) => call.args[0]);
1709
+ expect(executionQuoteRequests).to.have.lengthOf(2);
1710
+
1711
+ const forwardQuoteRequests = executionQuoteRequests.filter(
1712
+ (params) => params.fromAmount !== undefined,
1713
+ );
1714
+ const reverseQuoteRequests = executionQuoteRequests.filter(
1715
+ (params) => params.toAmount !== undefined,
1716
+ );
1717
+ const forwardAmounts = forwardQuoteRequests.map(
1718
+ (params) => params.fromAmount as bigint,
1719
+ );
1720
+
1721
+ expect(
1722
+ forwardQuoteRequests.length + reverseQuoteRequests.length,
1723
+ ).to.equal(2);
1724
+ expect(forwardQuoteRequests.length).to.be.greaterThan(0);
1725
+ expect(forwardAmounts.every((amount) => amount <= maxPerSourceOutput)).to
1726
+ .be.true;
1727
+ expect(
1728
+ reverseQuoteRequests.every(
1729
+ (params) => (params.toAmount as bigint) <= maxPerSourceOutput,
1730
+ ),
1731
+ ).to.be.true;
1732
+ });
1733
+
1460
1734
  it('applies 5% buffer to total bridge amount', async () => {
1461
1735
  // Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
1462
1736
  const amount = BigInt(1e18); // 1 ETH
@@ -1471,14 +1745,19 @@ describe('InventoryRebalancer E2E', () => {
1471
1745
  [ARBITRUM_CHAIN]: availableInventory,
1472
1746
  });
1473
1747
 
1474
- // Capture the quote amount from executeInventoryMovement
1475
- // (calculateMaxViableBridgeAmount doesn't quote for ERC20 tokens)
1476
- let quotedFromAmount: bigint | undefined;
1748
+ let quotedTargetOutput: bigint | undefined;
1477
1749
  bridge.quote.callsFake(async (params: any) => {
1478
- quotedFromAmount = params.fromAmount;
1750
+ if (params.toAmount !== undefined) {
1751
+ quotedTargetOutput = params.toAmount;
1752
+ }
1479
1753
  return createMockBridgeQuote({
1480
1754
  fromAmount: params.fromAmount ?? params.toAmount,
1481
- toAmount: params.fromAmount ?? params.toAmount,
1755
+ toAmount: params.toAmount ?? params.fromAmount,
1756
+ toAmountMin: params.toAmount ?? params.fromAmount,
1757
+ requestParams:
1758
+ params.toAmount !== undefined
1759
+ ? { ...params, toAmount: params.toAmount }
1760
+ : { ...params, fromAmount: params.fromAmount },
1482
1761
  });
1483
1762
  });
1484
1763
  bridge.execute.resolves({
@@ -1490,10 +1769,465 @@ describe('InventoryRebalancer E2E', () => {
1490
1769
  await inventoryRebalancer.rebalance([route]);
1491
1770
 
1492
1771
  // 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
1772
  const expectedWithBuffer = (amount * 105n) / 100n;
1496
- expect(quotedFromAmount).to.equal(expectedWithBuffer);
1773
+ expect(quotedTargetOutput).to.equal(expectedWithBuffer);
1774
+ });
1775
+
1776
+ it('bridges only the mixed-decimal shortfall after dust partial skip', async () => {
1777
+ const canonicalAmount = 1n;
1778
+ const destinationDust = 1_000_000_000_000n - 1n;
1779
+ const sourceInventory = 1_000_000_000_000n;
1780
+ warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
1781
+ numerator: 1n,
1782
+ denominator: 1_000_000_000_000n,
1783
+ };
1784
+
1785
+ const route = createTestRoute({ amount: canonicalAmount });
1786
+ createTestIntent({ amount: canonicalAmount });
1787
+
1788
+ inventoryRebalancer.setInventoryBalances({
1789
+ [SOLANA_CHAIN]: destinationDust,
1790
+ [ARBITRUM_CHAIN]: sourceInventory,
1791
+ });
1792
+
1793
+ let reverseQuoteTargetOutput: bigint | undefined;
1794
+ bridge.quote.callsFake(async (params: any) => {
1795
+ if (params.toAmount !== undefined) {
1796
+ reverseQuoteTargetOutput = params.toAmount;
1797
+ }
1798
+ return createMockBridgeQuote({
1799
+ fromAmount: params.fromAmount ?? params.toAmount,
1800
+ toAmount: params.toAmount ?? params.fromAmount,
1801
+ toAmountMin: params.toAmount ?? params.fromAmount,
1802
+ requestParams:
1803
+ params.toAmount !== undefined
1804
+ ? { ...params, toAmount: params.toAmount }
1805
+ : { ...params, fromAmount: params.fromAmount },
1806
+ });
1807
+ });
1808
+ bridge.execute.resolves({
1809
+ txHash: '0xBridgeTxHash',
1810
+ fromChain: 42161,
1811
+ toChain: 1399811149,
1812
+ });
1813
+
1814
+ const results = await inventoryRebalancer.rebalance([route]);
1815
+
1816
+ expect(results).to.have.lengthOf(1);
1817
+ expect(results[0].success).to.be.true;
1818
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
1819
+ expect(bridge.execute.calledOnce).to.be.true;
1820
+ expect(reverseQuoteTargetOutput).to.equal(1n);
1821
+ });
1822
+
1823
+ it('plans LiFi target output in destination-local units for mixed-decimal routes', async () => {
1824
+ const canonicalAmount = 1_000_000n;
1825
+ const availableInventory = BigInt(2e18);
1826
+ warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
1827
+ numerator: 1n,
1828
+ denominator: 1_000_000_000_000n,
1829
+ };
1830
+
1831
+ const route = createTestRoute({ amount: canonicalAmount });
1832
+ createTestIntent({ amount: canonicalAmount });
1833
+
1834
+ inventoryRebalancer.setInventoryBalances({
1835
+ [SOLANA_CHAIN]: 0n,
1836
+ [ARBITRUM_CHAIN]: availableInventory,
1837
+ });
1838
+
1839
+ let quotedTargetOutput: bigint | undefined;
1840
+ bridge.quote.callsFake(async (params: any) => {
1841
+ if (params.toAmount !== undefined) {
1842
+ quotedTargetOutput = params.toAmount;
1843
+ }
1844
+ return createMockBridgeQuote({
1845
+ fromAmount: params.fromAmount ?? params.toAmount,
1846
+ toAmount: params.toAmount ?? params.fromAmount,
1847
+ toAmountMin: params.toAmount ?? params.fromAmount,
1848
+ requestParams:
1849
+ params.toAmount !== undefined
1850
+ ? { ...params, toAmount: params.toAmount }
1851
+ : { ...params, fromAmount: params.fromAmount },
1852
+ });
1853
+ });
1854
+ bridge.execute.resolves({
1855
+ txHash: '0xBridgeTxHash',
1856
+ fromChain: 42161,
1857
+ toChain: 1399811149,
1858
+ });
1859
+
1860
+ await inventoryRebalancer.rebalance([route]);
1861
+
1862
+ expect(quotedTargetOutput).to.equal(1_050_000_000_000_000_000n);
1863
+ });
1864
+
1865
+ it('retries forward execution when reverse quote exceeds planned source capacity', async () => {
1866
+ const amount = BigInt(1e18);
1867
+ const sourceInventory = BigInt(2e18);
1868
+ const targetWithBuffer = (amount * 105n) / 100n;
1869
+
1870
+ const route = createTestRoute({ amount });
1871
+ createTestIntent({ amount });
1872
+
1873
+ inventoryRebalancer.setInventoryBalances({
1874
+ [SOLANA_CHAIN]: 0n,
1875
+ [ARBITRUM_CHAIN]: sourceInventory,
1876
+ });
1877
+
1878
+ bridge.quote
1879
+ .onFirstCall()
1880
+ .resolves(
1881
+ createMockBridgeQuote({
1882
+ fromAmount: sourceInventory,
1883
+ toAmount: sourceInventory,
1884
+ toAmountMin: sourceInventory,
1885
+ requestParams: {
1886
+ fromChain: 42161,
1887
+ toChain: 1399811149,
1888
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1889
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1890
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1891
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1892
+ fromAmount: sourceInventory,
1893
+ },
1894
+ }),
1895
+ )
1896
+ .onSecondCall()
1897
+ .resolves(
1898
+ createMockBridgeQuote({
1899
+ fromAmount: sourceInventory + 1n,
1900
+ toAmount: targetWithBuffer,
1901
+ toAmountMin: targetWithBuffer,
1902
+ requestParams: {
1903
+ fromChain: 42161,
1904
+ toChain: 1399811149,
1905
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1906
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1907
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1908
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1909
+ toAmount: targetWithBuffer,
1910
+ },
1911
+ }),
1912
+ )
1913
+ .onThirdCall()
1914
+ .resolves(
1915
+ createMockBridgeQuote({
1916
+ fromAmount: sourceInventory,
1917
+ toAmount: targetWithBuffer - 1n,
1918
+ toAmountMin: targetWithBuffer - 1n,
1919
+ requestParams: {
1920
+ fromChain: 42161,
1921
+ toChain: 1399811149,
1922
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1923
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1924
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1925
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1926
+ fromAmount: sourceInventory,
1927
+ },
1928
+ }),
1929
+ );
1930
+
1931
+ bridge.execute.resolves({
1932
+ txHash: '0xBridgeTxHash',
1933
+ fromChain: 42161,
1934
+ toChain: 1399811149,
1935
+ });
1936
+
1937
+ const results = await inventoryRebalancer.rebalance([route]);
1938
+
1939
+ expect(results).to.have.lengthOf(1);
1940
+ expect(results[0].success).to.be.true;
1941
+ expect(bridge.execute.calledOnce).to.be.true;
1942
+ expect(bridge.quote.callCount).to.equal(3);
1943
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
1944
+ targetWithBuffer,
1945
+ );
1946
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(
1947
+ sourceInventory,
1948
+ );
1949
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
1950
+ });
1951
+
1952
+ it('fails when forward retry still exceeds planned source capacity', async () => {
1953
+ const amount = BigInt(1e18);
1954
+ const sourceInventory = BigInt(2e18);
1955
+ const targetWithBuffer = (amount * 105n) / 100n;
1956
+
1957
+ const route = createTestRoute({ amount });
1958
+ createTestIntent({ amount });
1959
+
1960
+ inventoryRebalancer.setInventoryBalances({
1961
+ [SOLANA_CHAIN]: 0n,
1962
+ [ARBITRUM_CHAIN]: sourceInventory,
1963
+ });
1964
+
1965
+ bridge.quote
1966
+ .onFirstCall()
1967
+ .resolves(
1968
+ createMockBridgeQuote({
1969
+ fromAmount: sourceInventory,
1970
+ toAmount: sourceInventory,
1971
+ toAmountMin: sourceInventory,
1972
+ requestParams: {
1973
+ fromChain: 42161,
1974
+ toChain: 1399811149,
1975
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1976
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1977
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1978
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1979
+ fromAmount: sourceInventory,
1980
+ },
1981
+ }),
1982
+ )
1983
+ .onSecondCall()
1984
+ .resolves(
1985
+ createMockBridgeQuote({
1986
+ fromAmount: sourceInventory + 1n,
1987
+ toAmount: targetWithBuffer,
1988
+ toAmountMin: targetWithBuffer,
1989
+ requestParams: {
1990
+ fromChain: 42161,
1991
+ toChain: 1399811149,
1992
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1993
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1994
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1995
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1996
+ toAmount: targetWithBuffer,
1997
+ },
1998
+ }),
1999
+ )
2000
+ .onThirdCall()
2001
+ .resolves(
2002
+ createMockBridgeQuote({
2003
+ fromAmount: sourceInventory + 1n,
2004
+ toAmount: targetWithBuffer - 1n,
2005
+ toAmountMin: targetWithBuffer - 1n,
2006
+ requestParams: {
2007
+ fromChain: 42161,
2008
+ toChain: 1399811149,
2009
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2010
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2011
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2012
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2013
+ fromAmount: sourceInventory,
2014
+ },
2015
+ }),
2016
+ );
2017
+
2018
+ const results = await inventoryRebalancer.rebalance([route]);
2019
+
2020
+ expect(results).to.have.lengthOf(1);
2021
+ expect(results[0].success).to.be.false;
2022
+ expect(results[0].error).to.include('All inventory movements failed');
2023
+ expect(results[0].error).to.include('exceeded planned source capacity');
2024
+ expect(bridge.quote.callCount).to.equal(3);
2025
+ expect(bridge.execute.called).to.be.false;
2026
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
2027
+ targetWithBuffer,
2028
+ );
2029
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(
2030
+ sourceInventory,
2031
+ );
2032
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
2033
+ });
2034
+
2035
+ it('fails gracefully when execute-time bridge quote rejects', async () => {
2036
+ const amount = BigInt(1e18);
2037
+ const sourceInventory = BigInt(2e18);
2038
+
2039
+ const route = createTestRoute({ amount });
2040
+ createTestIntent({ amount });
2041
+
2042
+ inventoryRebalancer.setInventoryBalances({
2043
+ [SOLANA_CHAIN]: 0n,
2044
+ [ARBITRUM_CHAIN]: sourceInventory,
2045
+ });
2046
+
2047
+ bridge.quote
2048
+ .onFirstCall()
2049
+ .resolves(
2050
+ createMockBridgeQuote({
2051
+ fromAmount: sourceInventory,
2052
+ toAmount: sourceInventory,
2053
+ toAmountMin: sourceInventory,
2054
+ requestParams: {
2055
+ fromChain: 42161,
2056
+ toChain: 1399811149,
2057
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2058
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2059
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2060
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2061
+ fromAmount: sourceInventory,
2062
+ },
2063
+ }),
2064
+ )
2065
+ .onSecondCall()
2066
+ .rejects(new Error('LiFi API timeout'));
2067
+
2068
+ const results = await inventoryRebalancer.rebalance([route]);
2069
+
2070
+ expect(results).to.have.lengthOf(1);
2071
+ expect(results[0].success).to.be.false;
2072
+ expect(results[0].error).to.include('All inventory movements failed');
2073
+ expect(results[0].error).to.include('LiFi API timeout');
2074
+ expect(bridge.quote.callCount).to.equal(2);
2075
+ expect(bridge.execute.called).to.be.false;
2076
+ });
2077
+
2078
+ it('preserves non-Error rejection reasons from parallel bridge execution', async () => {
2079
+ const amount = BigInt(1e18);
2080
+ const sourceInventory = BigInt(2e18);
2081
+
2082
+ const route = createTestRoute({ amount });
2083
+ createTestIntent({ amount });
2084
+
2085
+ inventoryRebalancer.setInventoryBalances({
2086
+ [SOLANA_CHAIN]: 0n,
2087
+ [ARBITRUM_CHAIN]: sourceInventory,
2088
+ });
2089
+
2090
+ bridge.quote.onFirstCall().resolves(
2091
+ createMockBridgeQuote({
2092
+ fromAmount: sourceInventory,
2093
+ toAmount: sourceInventory,
2094
+ toAmountMin: sourceInventory,
2095
+ requestParams: {
2096
+ fromChain: 42161,
2097
+ toChain: 1399811149,
2098
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2099
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2100
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2101
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2102
+ fromAmount: sourceInventory,
2103
+ },
2104
+ }),
2105
+ );
2106
+
2107
+ // Simulate a foreign promise rejecting with a non-Error reason at runtime.
2108
+ const rejectionReason = 'bridge exploded' as unknown as Error;
2109
+
2110
+ const executeInventoryMovementStub = Sinon.stub(
2111
+ inventoryRebalancer as unknown as {
2112
+ executeInventoryMovement: () => Promise<unknown>;
2113
+ },
2114
+ 'executeInventoryMovement',
2115
+ ).callsFake(() =>
2116
+ Promise.resolve().then(() => {
2117
+ throw rejectionReason;
2118
+ }),
2119
+ );
2120
+
2121
+ const results = await inventoryRebalancer.rebalance([route]);
2122
+
2123
+ expect(results).to.have.lengthOf(1);
2124
+ expect(results[0].success).to.be.false;
2125
+ expect(results[0].error).to.include('All inventory movements failed');
2126
+ expect(results[0].error).to.include('arbitrum: bridge exploded');
2127
+ expect(executeInventoryMovementStub.calledOnce).to.be.true;
2128
+ expect(bridge.execute.called).to.be.false;
2129
+
2130
+ executeInventoryMovementStub.restore();
2131
+ });
2132
+
2133
+ it('logs actual successful bridge output floors instead of planned output totals', async () => {
2134
+ const amount = BigInt(1e18);
2135
+ const perChainInventory = BigInt(0.6e18);
2136
+ const logger = {
2137
+ info: Sinon.stub(),
2138
+ debug: Sinon.stub(),
2139
+ warn: Sinon.stub(),
2140
+ error: Sinon.stub(),
2141
+ };
2142
+
2143
+ const localRebalancer = new InventoryRebalancer(
2144
+ config,
2145
+ actionTracker as unknown as IActionTracker,
2146
+ { lifi: bridge as unknown as IExternalBridge },
2147
+ warpCore as unknown as WarpCore,
2148
+ multiProvider as unknown as MultiProvider,
2149
+ logger as unknown as Logger,
2150
+ );
2151
+
2152
+ const route = createTestRoute({ amount });
2153
+ createTestIntent({ amount });
2154
+
2155
+ localRebalancer.setInventoryBalances({
2156
+ [SOLANA_CHAIN]: 0n,
2157
+ [ARBITRUM_CHAIN]: perChainInventory,
2158
+ [BASE_CHAIN]: perChainInventory,
2159
+ });
2160
+
2161
+ bridge.quote
2162
+ .onCall(0)
2163
+ .resolves(
2164
+ createMockBridgeQuote({
2165
+ fromAmount: perChainInventory,
2166
+ toAmount: BigInt(0.525e18),
2167
+ toAmountMin: BigInt(0.525e18),
2168
+ }),
2169
+ )
2170
+ .onCall(1)
2171
+ .resolves(
2172
+ createMockBridgeQuote({
2173
+ fromAmount: perChainInventory,
2174
+ toAmount: BigInt(0.525e18),
2175
+ toAmountMin: BigInt(0.525e18),
2176
+ }),
2177
+ )
2178
+ .onCall(2)
2179
+ .resolves(
2180
+ createMockBridgeQuote({
2181
+ fromAmount: perChainInventory,
2182
+ toAmount: BigInt(0.505e18),
2183
+ toAmountMin: BigInt(0.5e18),
2184
+ }),
2185
+ )
2186
+ .onCall(3)
2187
+ .resolves(
2188
+ createMockBridgeQuote({
2189
+ fromAmount: perChainInventory,
2190
+ toAmount: BigInt(0.515e18),
2191
+ toAmountMin: BigInt(0.51e18),
2192
+ }),
2193
+ );
2194
+
2195
+ bridge.execute
2196
+ .onFirstCall()
2197
+ .resolves({
2198
+ txHash: '0xSuccessTxHash1',
2199
+ fromChain: 42161,
2200
+ toChain: 1399811149,
2201
+ })
2202
+ .onSecondCall()
2203
+ .resolves({
2204
+ txHash: '0xSuccessTxHash2',
2205
+ fromChain: 8453,
2206
+ toChain: 1399811149,
2207
+ });
2208
+
2209
+ const results = await localRebalancer.rebalance([route]);
2210
+
2211
+ expect(results).to.have.lengthOf(1);
2212
+ expect(results[0].success).to.be.true;
2213
+ expect(bridge.execute.callCount).to.equal(2);
2214
+
2215
+ const summaryCall = logger.info
2216
+ .getCalls()
2217
+ .find(
2218
+ (call: ReturnType<typeof logger.info.getCall>) =>
2219
+ call.args[0] &&
2220
+ typeof call.args[0] === 'object' &&
2221
+ Object.prototype.hasOwnProperty.call(
2222
+ call.args[0],
2223
+ 'totalQuotedOutputMin',
2224
+ ),
2225
+ );
2226
+
2227
+ assert(summaryCall, 'Expected summary log with totalQuotedOutputMin');
2228
+ expect(summaryCall.args[0].totalQuotedOutputMin).to.equal(
2229
+ BigInt(1.01e18).toString(),
2230
+ );
1497
2231
  });
1498
2232
 
1499
2233
  it('continues when some bridges fail', async () => {
@@ -1721,14 +2455,36 @@ describe('InventoryRebalancer E2E', () => {
1721
2455
  });
1722
2456
 
1723
2457
  // 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
- );
2458
+ bridge.quote
2459
+ .onFirstCall()
2460
+ .resolves(
2461
+ createMockBridgeQuote({
2462
+ fromAmount: tokenBalance,
2463
+ toAmount: tokenBalance,
2464
+ toAmountMin: tokenBalance,
2465
+ gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
2466
+ feeCosts: 0n,
2467
+ }),
2468
+ )
2469
+ .onSecondCall()
2470
+ .resolves(
2471
+ createMockBridgeQuote({
2472
+ fromAmount: tokenBalance,
2473
+ toAmount: tokenBalance,
2474
+ toAmountMin: tokenBalance,
2475
+ gasCosts: BigInt(1e18),
2476
+ feeCosts: 0n,
2477
+ requestParams: {
2478
+ fromChain: 42161,
2479
+ toChain: 1399811149,
2480
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2481
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2482
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2483
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2484
+ toAmount: tokenBalance,
2485
+ },
2486
+ }),
2487
+ );
1732
2488
 
1733
2489
  bridge.execute.resolves({
1734
2490
  txHash: '0xERC20BridgeTxHash',
@@ -1742,6 +2498,9 @@ describe('InventoryRebalancer E2E', () => {
1742
2498
  expect(results).to.have.lengthOf(1);
1743
2499
  expect(results[0].success).to.be.true;
1744
2500
  expect(bridge.execute.calledOnce).to.be.true;
2501
+ expect(bridge.quote.callCount).to.equal(2);
2502
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(tokenBalance);
2503
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
1745
2504
  });
1746
2505
 
1747
2506
  it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
@@ -1777,14 +2536,47 @@ describe('InventoryRebalancer E2E', () => {
1777
2536
  // maxViable = 1 ETH - 0.02 ETH = 0.98 ETH
1778
2537
  // 10% threshold = 0.1 ETH, estimatedGas (0.02) < threshold → viable
1779
2538
  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
- );
2539
+ const maxViable = rawBalance - gasCosts * 20n;
2540
+ bridge.quote
2541
+ .onFirstCall()
2542
+ .resolves(
2543
+ createMockBridgeQuote({
2544
+ fromAmount: rawBalance,
2545
+ toAmount: rawBalance - BigInt(1e15),
2546
+ toAmountMin: rawBalance - BigInt(1e15),
2547
+ gasCosts,
2548
+ feeCosts: 0n,
2549
+ }),
2550
+ )
2551
+ .onSecondCall()
2552
+ .resolves(
2553
+ createMockBridgeQuote({
2554
+ fromAmount: maxViable,
2555
+ toAmount: maxViable - BigInt(1e15),
2556
+ toAmountMin: maxViable - BigInt(1e15),
2557
+ gasCosts,
2558
+ feeCosts: 0n,
2559
+ }),
2560
+ )
2561
+ .onThirdCall()
2562
+ .resolves(
2563
+ createMockBridgeQuote({
2564
+ fromAmount: BigInt(0.525e18),
2565
+ toAmount: BigInt(0.525e18),
2566
+ toAmountMin: BigInt(0.525e18),
2567
+ gasCosts,
2568
+ feeCosts: 0n,
2569
+ requestParams: {
2570
+ fromChain: 42161,
2571
+ toChain: 1399811149,
2572
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2573
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2574
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2575
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2576
+ toAmount: BigInt(0.525e18),
2577
+ },
2578
+ }),
2579
+ );
1788
2580
 
1789
2581
  bridge.execute.resolves({
1790
2582
  txHash: '0xSuccessBridgeTxHash',
@@ -1798,19 +2590,121 @@ describe('InventoryRebalancer E2E', () => {
1798
2590
  expect(results).to.have.lengthOf(1);
1799
2591
  expect(results[0].success).to.be.true;
1800
2592
  expect(bridge.execute.calledOnce).to.be.true;
2593
+ expect(bridge.quote.callCount).to.equal(3);
2594
+
2595
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(maxViable);
2596
+ const executionTargetOutput = bridge.quote.getCall(2).args[0].toAmount;
2597
+ expect(executionTargetOutput).to.be.a('bigint');
2598
+ if (executionTargetOutput === undefined) {
2599
+ throw new Error('Expected reverse quote to set toAmount');
2600
+ }
2601
+ expect(executionTargetOutput > amount).to.be.true;
2602
+ expect(executionTargetOutput <= maxViable).to.be.true;
2603
+ });
2604
+
2605
+ it('uses forward quote when target output exactly matches source capacity', async () => {
2606
+ const amount = 1000n;
2607
+ const rawBalance = 1100n;
2608
+ const targetWithBuffer = 1050n;
2609
+
2610
+ const route = createTestRoute({ amount });
2611
+ createTestIntent({ amount });
2612
+
2613
+ inventoryRebalancer.setInventoryBalances({
2614
+ [SOLANA_CHAIN]: 0n,
2615
+ [ARBITRUM_CHAIN]: rawBalance,
2616
+ });
2617
+
2618
+ bridge.quote
2619
+ .onFirstCall()
2620
+ .resolves(
2621
+ createMockBridgeQuote({
2622
+ fromAmount: rawBalance,
2623
+ toAmount: targetWithBuffer,
2624
+ toAmountMin: targetWithBuffer,
2625
+ }),
2626
+ )
2627
+ .onSecondCall()
2628
+ .resolves(
2629
+ createMockBridgeQuote({
2630
+ fromAmount: rawBalance,
2631
+ toAmount: targetWithBuffer,
2632
+ toAmountMin: targetWithBuffer,
2633
+ }),
2634
+ );
2635
+
2636
+ bridge.execute.resolves({
2637
+ txHash: '0xForwardBoundaryBridgeTxHash',
2638
+ fromChain: 42161,
2639
+ toChain: 1399811149,
2640
+ });
2641
+
2642
+ const results = await inventoryRebalancer.rebalance([route]);
2643
+
2644
+ expect(results).to.have.lengthOf(1);
2645
+ expect(results[0].success).to.be.true;
2646
+ expect(bridge.execute.calledOnce).to.be.true;
2647
+ expect(bridge.quote.callCount).to.equal(2);
2648
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(rawBalance);
2649
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
2650
+ });
1801
2651
 
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,
2652
+ it('retries reverse quote as forward when exact-output exceeds source capacity', async () => {
2653
+ const amount = 1000n;
2654
+ const rawBalance = 1100n;
2655
+ const targetWithBuffer = 1050n;
2656
+
2657
+ const route = createTestRoute({ amount });
2658
+ createTestIntent({ amount });
2659
+
2660
+ inventoryRebalancer.setInventoryBalances({
2661
+ [SOLANA_CHAIN]: 0n,
2662
+ [ARBITRUM_CHAIN]: rawBalance,
2663
+ });
2664
+
2665
+ bridge.quote
2666
+ .onFirstCall()
2667
+ .resolves(
2668
+ createMockBridgeQuote({
2669
+ fromAmount: rawBalance,
2670
+ toAmount: 1051n,
2671
+ toAmountMin: 1051n,
2672
+ }),
2673
+ )
2674
+ .onSecondCall()
2675
+ .resolves(
2676
+ createMockBridgeQuote({
2677
+ fromAmount: rawBalance + 1n,
2678
+ toAmount: targetWithBuffer,
2679
+ toAmountMin: targetWithBuffer,
2680
+ }),
2681
+ )
2682
+ .onThirdCall()
2683
+ .resolves(
2684
+ createMockBridgeQuote({
2685
+ fromAmount: rawBalance,
2686
+ toAmount: 1049n,
2687
+ toAmountMin: 1049n,
2688
+ }),
1811
2689
  );
1812
- // Since maxViable (0.98 ETH) > targetWithBuffer (0.525 ETH), we bridge exactly targetWithBuffer
1813
- expect(executionQuoteCall).to.exist;
2690
+
2691
+ bridge.execute.resolves({
2692
+ txHash: '0xForwardFallbackBridgeTxHash',
2693
+ fromChain: 42161,
2694
+ toChain: 1399811149,
2695
+ });
2696
+
2697
+ const results = await inventoryRebalancer.rebalance([route]);
2698
+
2699
+ expect(results).to.have.lengthOf(1);
2700
+ expect(results[0].success).to.be.true;
2701
+ expect(bridge.execute.calledOnce).to.be.true;
2702
+ expect(bridge.quote.callCount).to.equal(3);
2703
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
2704
+ targetWithBuffer,
2705
+ );
2706
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(rawBalance);
2707
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
1814
2708
  });
1815
2709
 
1816
2710
  it('handles quote failures gracefully by skipping the source chain', async () => {