@hyperlane-xyz/rebalancer 27.2.13 → 27.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/bridges/LiFiBridge.d.ts +3 -3
  2. package/dist/bridges/LiFiBridge.d.ts.map +1 -1
  3. package/dist/bridges/LiFiBridge.js +60 -52
  4. package/dist/bridges/LiFiBridge.js.map +1 -1
  5. package/dist/bridges/LiFiBridge.test.js +213 -0
  6. package/dist/bridges/LiFiBridge.test.js.map +1 -1
  7. package/dist/config/RebalancerConfig.test.js +123 -0
  8. package/dist/config/RebalancerConfig.test.js.map +1 -1
  9. package/dist/config/types.d.ts.map +1 -1
  10. package/dist/config/types.js +9 -0
  11. package/dist/config/types.js.map +1 -1
  12. package/dist/core/InventoryRebalancer.d.ts +4 -2
  13. package/dist/core/InventoryRebalancer.d.ts.map +1 -1
  14. package/dist/core/InventoryRebalancer.js +84 -25
  15. package/dist/core/InventoryRebalancer.js.map +1 -1
  16. package/dist/core/InventoryRebalancer.test.js +436 -21
  17. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  18. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  19. package/dist/factories/RebalancerContextFactory.js +34 -24
  20. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  21. package/dist/factories/RebalancerContextFactory.test.js +84 -1
  22. package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
  23. package/dist/service.js +7 -4
  24. package/dist/service.js.map +1 -1
  25. package/dist/utils/blockTag.d.ts.map +1 -1
  26. package/dist/utils/blockTag.js +8 -3
  27. package/dist/utils/blockTag.js.map +1 -1
  28. package/dist/utils/blockTag.test.d.ts +2 -0
  29. package/dist/utils/blockTag.test.d.ts.map +1 -0
  30. package/dist/utils/blockTag.test.js +57 -0
  31. package/dist/utils/blockTag.test.js.map +1 -0
  32. package/dist/utils/gasEstimation.js +4 -4
  33. package/dist/utils/gasEstimation.js.map +1 -1
  34. package/dist/utils/gasEstimation.test.d.ts +2 -0
  35. package/dist/utils/gasEstimation.test.d.ts.map +1 -0
  36. package/dist/utils/gasEstimation.test.js +63 -0
  37. package/dist/utils/gasEstimation.test.js.map +1 -0
  38. package/dist/utils/tokenUtils.d.ts.map +1 -1
  39. package/dist/utils/tokenUtils.js +5 -2
  40. package/dist/utils/tokenUtils.js.map +1 -1
  41. package/package.json +7 -7
  42. package/src/bridges/LiFiBridge.test.ts +270 -0
  43. package/src/bridges/LiFiBridge.ts +83 -68
  44. package/src/config/RebalancerConfig.test.ts +135 -0
  45. package/src/config/types.ts +8 -0
  46. package/src/core/InventoryRebalancer.test.ts +610 -21
  47. package/src/core/InventoryRebalancer.ts +121 -30
  48. package/src/factories/RebalancerContextFactory.test.ts +116 -1
  49. package/src/factories/RebalancerContextFactory.ts +38 -28
  50. package/src/service.ts +11 -8
  51. package/src/utils/blockTag.test.ts +70 -0
  52. package/src/utils/blockTag.ts +11 -3
  53. package/src/utils/gasEstimation.test.ts +99 -0
  54. package/src/utils/gasEstimation.ts +4 -4
  55. package/src/utils/tokenUtils.ts +5 -2
@@ -3,8 +3,8 @@ import chaiAsPromised from 'chai-as-promised';
3
3
  import { Wallet } from 'ethers';
4
4
  import { pino } from 'pino';
5
5
  import Sinon from 'sinon';
6
- import { ProviderType, TokenStandard, WarpTxCategory, } from '@hyperlane-xyz/sdk';
7
- import { ProtocolType } from '@hyperlane-xyz/utils';
6
+ import { HyperlaneCore, ProviderType, TokenStandard, WarpTxCategory, } from '@hyperlane-xyz/sdk';
7
+ import { assert, ProtocolType } from '@hyperlane-xyz/utils';
8
8
  import { ExternalBridgeType } from '../config/types.js';
9
9
  import { createMockBridgeQuote } from '../test/lifiMocks.js';
10
10
  import { InventoryRebalancer, } from './InventoryRebalancer.js';
@@ -356,6 +356,113 @@ describe('InventoryRebalancer E2E', () => {
356
356
  expect(actionParams.amount).to.equal(1000000n);
357
357
  });
358
358
  });
359
+ it('sendAndConfirmInventoryTx uses the EVM-like signer path for Tron txs', async () => {
360
+ const TRON_CHAIN = 'tron';
361
+ config.inventorySigners[ProtocolType.Tron] = {
362
+ address: INVENTORY_SIGNER,
363
+ key: TEST_PRIVATE_KEY,
364
+ };
365
+ const getChainMetadata = (chain) => ({
366
+ name: chain,
367
+ protocol: chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
368
+ blocks: { reorgPeriod: 2 },
369
+ rpcUrls: [{ http: 'http://127.0.0.1:9090/jsonrpc' }],
370
+ });
371
+ warpCore.multiProvider.getChainMetadata.callsFake(getChainMetadata);
372
+ multiProvider.getChainMetadata.callsFake(getChainMetadata);
373
+ warpCore.multiProvider.toMultiProvider =
374
+ Sinon.stub().returns(multiProvider);
375
+ const sendFn = inventoryRebalancer.sendAndConfirmInventoryTx.bind(inventoryRebalancer);
376
+ const result = await sendFn(TRON_CHAIN, {
377
+ category: WarpTxCategory.Transfer,
378
+ type: ProviderType.Tron,
379
+ transaction: {
380
+ to: '0xRouterAddress',
381
+ data: '0xTransferRemoteData',
382
+ value: 1000000n,
383
+ },
384
+ });
385
+ expect(result).to.deep.equal({ txHash: '0xTransferRemoteTxHash' });
386
+ expect(multiProvider.setSigner.calledOnce).to.be.true;
387
+ expect(multiProvider.sendTransaction.calledOnceWithExactly(TRON_CHAIN, {
388
+ to: '0xRouterAddress',
389
+ data: '0xTransferRemoteData',
390
+ value: 1000000n,
391
+ }, { waitConfirmations: 2 })).to.be.true;
392
+ });
393
+ it('buildSignerAccountConfig returns correct config for Tron protocol', () => {
394
+ config.inventorySigners[ProtocolType.Tron] = {
395
+ address: INVENTORY_SIGNER,
396
+ key: TEST_PRIVATE_KEY,
397
+ };
398
+ const buildFn = inventoryRebalancer.buildSignerAccountConfig.bind(inventoryRebalancer);
399
+ const result = buildFn(ProtocolType.Tron, TEST_PRIVATE_KEY, 'tron');
400
+ expect(result.protocol).to.equal(ProtocolType.Tron);
401
+ expect(result.privateKey).to.equal(TEST_PRIVATE_KEY);
402
+ // Tron uses same 0x-prefixed hex key format as Ethereum
403
+ expect(result.privateKey.startsWith('0x')).to.be.true;
404
+ });
405
+ it('getTransactionReceipt returns EthersV5 type for Tron chain', async () => {
406
+ const TRON_CHAIN = 'tron';
407
+ const mockReceipt = {
408
+ transactionHash: '0xTronReceipt',
409
+ logs: [],
410
+ };
411
+ warpCore.multiProvider.getChainMetadata.callsFake((chain) => ({
412
+ name: chain,
413
+ protocol: chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
414
+ }));
415
+ warpCore.multiProvider.getEthersV5Provider.returns({
416
+ getTransactionReceipt: Sinon.stub().resolves(mockReceipt),
417
+ });
418
+ const getReceiptFn = inventoryRebalancer.getTransactionReceipt.bind(inventoryRebalancer);
419
+ const receipt = await getReceiptFn(TRON_CHAIN, '0xTronTxHash');
420
+ expect(receipt).to.not.be.undefined;
421
+ expect(receipt.type).to.equal(ProviderType.EthersV5);
422
+ expect(receipt.receipt).to.deep.equal(mockReceipt);
423
+ });
424
+ it('extractDispatchedMessageId returns Tron message ids via the EthersV5 path', async () => {
425
+ const TRON_CHAIN = 'tron';
426
+ const expectedMessageId = '0x1111111111111111111111111111111111111111111111111111111111111111';
427
+ const mockReceipt = {
428
+ transactionHash: '0xTronTx',
429
+ logs: [{}],
430
+ };
431
+ warpCore.multiProvider.getChainMetadata.callsFake((chain) => ({
432
+ name: chain,
433
+ protocol: chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
434
+ }));
435
+ const getDispatchedMessagesStub = Sinon.stub(HyperlaneCore, 'getDispatchedMessages').returns([{ id: expectedMessageId }]);
436
+ warpCore.multiProvider.getEthersV5Provider.returns({
437
+ getTransactionReceipt: Sinon.stub().resolves(mockReceipt),
438
+ });
439
+ const extractFn = inventoryRebalancer.extractDispatchedMessageId.bind(inventoryRebalancer);
440
+ const messageId = await extractFn(TRON_CHAIN, '0xTronTx');
441
+ expect(messageId).to.equal(expectedMessageId);
442
+ expect(getDispatchedMessagesStub.calledOnce).to.be.true;
443
+ expect(getDispatchedMessagesStub.firstCall.firstArg).to.equal(mockReceipt);
444
+ });
445
+ it('selects the Tron signer address from a mixed multi-protocol config', () => {
446
+ config.inventorySigners[ProtocolType.Tron] = {
447
+ address: INVENTORY_SIGNER,
448
+ key: TEST_PRIVATE_KEY,
449
+ };
450
+ config.inventorySigners[ProtocolType.Sealevel] = {
451
+ address: 'SoLANAAddReSs1234567890123456789012345678',
452
+ key: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32',
453
+ };
454
+ warpCore.multiProvider.getChainMetadata.callsFake((chain) => ({
455
+ name: chain,
456
+ protocol: chain === 'tron'
457
+ ? ProtocolType.Tron
458
+ : chain === SOLANA_CHAIN
459
+ ? ProtocolType.Sealevel
460
+ : ProtocolType.Ethereum,
461
+ }));
462
+ const getInventorySignerAddress = inventoryRebalancer.getInventorySignerAddress.bind(inventoryRebalancer);
463
+ expect(getInventorySignerAddress('tron')).to.equal(INVENTORY_SIGNER);
464
+ expect(getInventorySignerAddress(SOLANA_CHAIN)).to.equal('SoLANAAddReSs1234567890123456789012345678');
465
+ });
359
466
  describe('Partial Fulfillment (Insufficient Inventory)', () => {
360
467
  // Partial transfers happen when maxTransferable >= minViableTransfer.
361
468
  // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
@@ -1373,22 +1480,20 @@ describe('InventoryRebalancer E2E', () => {
1373
1480
  expect(results).to.have.lengthOf(1);
1374
1481
  expect(results[0].success).to.be.true;
1375
1482
  expect(bridge.execute.callCount).to.equal(2);
1376
- const reverseQuoteRequests = bridge.quote
1377
- .getCalls()
1378
- .map((call) => call.args[0])
1379
- .filter((params) => params.toAmount !== undefined);
1380
- expect(reverseQuoteRequests).to.have.lengthOf(2);
1381
- const requestedOutputs = reverseQuoteRequests
1382
- .map((params) => params.toAmount)
1383
- .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1384
1483
  const maxPerSourceOutput = perChainInventory - reservedGas;
1385
- const totalAvailableCapacity = maxPerSourceOutput * 2n;
1386
- expect(requestedOutputs.every((output) => output <= maxPerSourceOutput))
1387
- .to.be.true;
1388
- expect(requestedOutputs[0] < maxPerSourceOutput).to.be.true;
1389
- expect(requestedOutputs[1]).to.equal(maxPerSourceOutput);
1390
- expect(requestedOutputs[0] + requestedOutputs[1] < totalAvailableCapacity)
1391
- .to.be.true;
1484
+ const executionQuoteRequests = bridge.quote
1485
+ .getCalls()
1486
+ .slice(-2)
1487
+ .map((call) => call.args[0]);
1488
+ expect(executionQuoteRequests).to.have.lengthOf(2);
1489
+ const forwardQuoteRequests = executionQuoteRequests.filter((params) => params.fromAmount !== undefined);
1490
+ const reverseQuoteRequests = executionQuoteRequests.filter((params) => params.toAmount !== undefined);
1491
+ const forwardAmounts = forwardQuoteRequests.map((params) => params.fromAmount);
1492
+ expect(forwardQuoteRequests.length + reverseQuoteRequests.length).to.equal(2);
1493
+ expect(forwardQuoteRequests.length).to.be.greaterThan(0);
1494
+ expect(forwardAmounts.every((amount) => amount <= maxPerSourceOutput)).to
1495
+ .be.true;
1496
+ expect(reverseQuoteRequests.every((params) => params.toAmount <= maxPerSourceOutput)).to.be.true;
1392
1497
  });
1393
1498
  it('applies 5% buffer to total bridge amount', async () => {
1394
1499
  // Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
@@ -1500,7 +1605,7 @@ describe('InventoryRebalancer E2E', () => {
1500
1605
  await inventoryRebalancer.rebalance([route]);
1501
1606
  expect(quotedTargetOutput).to.equal(1050000000000000000n);
1502
1607
  });
1503
- it('fails bridge execution when reverse quote exceeds planned source capacity', async () => {
1608
+ it('retries forward execution when reverse quote exceeds planned source capacity', async () => {
1504
1609
  const amount = BigInt(1e18);
1505
1610
  const sourceInventory = BigInt(2e18);
1506
1611
  const targetWithBuffer = (amount * 105n) / 100n;
@@ -1540,13 +1645,241 @@ describe('InventoryRebalancer E2E', () => {
1540
1645
  toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1541
1646
  toAmount: targetWithBuffer,
1542
1647
  },
1648
+ }))
1649
+ .onThirdCall()
1650
+ .resolves(createMockBridgeQuote({
1651
+ fromAmount: sourceInventory,
1652
+ toAmount: targetWithBuffer - 1n,
1653
+ toAmountMin: targetWithBuffer - 1n,
1654
+ requestParams: {
1655
+ fromChain: 42161,
1656
+ toChain: 1399811149,
1657
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1658
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1659
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1660
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1661
+ fromAmount: sourceInventory,
1662
+ },
1663
+ }));
1664
+ bridge.execute.resolves({
1665
+ txHash: '0xBridgeTxHash',
1666
+ fromChain: 42161,
1667
+ toChain: 1399811149,
1668
+ });
1669
+ const results = await inventoryRebalancer.rebalance([route]);
1670
+ expect(results).to.have.lengthOf(1);
1671
+ expect(results[0].success).to.be.true;
1672
+ expect(bridge.execute.calledOnce).to.be.true;
1673
+ expect(bridge.quote.callCount).to.equal(3);
1674
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
1675
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(sourceInventory);
1676
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
1677
+ });
1678
+ it('fails when forward retry still exceeds planned source capacity', async () => {
1679
+ const amount = BigInt(1e18);
1680
+ const sourceInventory = BigInt(2e18);
1681
+ const targetWithBuffer = (amount * 105n) / 100n;
1682
+ const route = createTestRoute({ amount });
1683
+ createTestIntent({ amount });
1684
+ inventoryRebalancer.setInventoryBalances({
1685
+ [SOLANA_CHAIN]: 0n,
1686
+ [ARBITRUM_CHAIN]: sourceInventory,
1687
+ });
1688
+ bridge.quote
1689
+ .onFirstCall()
1690
+ .resolves(createMockBridgeQuote({
1691
+ fromAmount: sourceInventory,
1692
+ toAmount: sourceInventory,
1693
+ toAmountMin: sourceInventory,
1694
+ requestParams: {
1695
+ fromChain: 42161,
1696
+ toChain: 1399811149,
1697
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1698
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1699
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1700
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1701
+ fromAmount: sourceInventory,
1702
+ },
1703
+ }))
1704
+ .onSecondCall()
1705
+ .resolves(createMockBridgeQuote({
1706
+ fromAmount: sourceInventory + 1n,
1707
+ toAmount: targetWithBuffer,
1708
+ toAmountMin: targetWithBuffer,
1709
+ requestParams: {
1710
+ fromChain: 42161,
1711
+ toChain: 1399811149,
1712
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1713
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1714
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1715
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1716
+ toAmount: targetWithBuffer,
1717
+ },
1718
+ }))
1719
+ .onThirdCall()
1720
+ .resolves(createMockBridgeQuote({
1721
+ fromAmount: sourceInventory + 1n,
1722
+ toAmount: targetWithBuffer - 1n,
1723
+ toAmountMin: targetWithBuffer - 1n,
1724
+ requestParams: {
1725
+ fromChain: 42161,
1726
+ toChain: 1399811149,
1727
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1728
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1729
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1730
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1731
+ fromAmount: sourceInventory,
1732
+ },
1543
1733
  }));
1544
1734
  const results = await inventoryRebalancer.rebalance([route]);
1545
1735
  expect(results).to.have.lengthOf(1);
1546
1736
  expect(results[0].success).to.be.false;
1547
1737
  expect(results[0].error).to.include('All inventory movements failed');
1548
1738
  expect(results[0].error).to.include('exceeded planned source capacity');
1739
+ expect(bridge.quote.callCount).to.equal(3);
1549
1740
  expect(bridge.execute.called).to.be.false;
1741
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
1742
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(sourceInventory);
1743
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
1744
+ });
1745
+ it('fails gracefully when execute-time bridge quote rejects', async () => {
1746
+ const amount = BigInt(1e18);
1747
+ const sourceInventory = BigInt(2e18);
1748
+ const route = createTestRoute({ amount });
1749
+ createTestIntent({ amount });
1750
+ inventoryRebalancer.setInventoryBalances({
1751
+ [SOLANA_CHAIN]: 0n,
1752
+ [ARBITRUM_CHAIN]: sourceInventory,
1753
+ });
1754
+ bridge.quote
1755
+ .onFirstCall()
1756
+ .resolves(createMockBridgeQuote({
1757
+ fromAmount: sourceInventory,
1758
+ toAmount: sourceInventory,
1759
+ toAmountMin: sourceInventory,
1760
+ requestParams: {
1761
+ fromChain: 42161,
1762
+ toChain: 1399811149,
1763
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1764
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1765
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1766
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1767
+ fromAmount: sourceInventory,
1768
+ },
1769
+ }))
1770
+ .onSecondCall()
1771
+ .rejects(new Error('LiFi API timeout'));
1772
+ const results = await inventoryRebalancer.rebalance([route]);
1773
+ expect(results).to.have.lengthOf(1);
1774
+ expect(results[0].success).to.be.false;
1775
+ expect(results[0].error).to.include('All inventory movements failed');
1776
+ expect(results[0].error).to.include('LiFi API timeout');
1777
+ expect(bridge.quote.callCount).to.equal(2);
1778
+ expect(bridge.execute.called).to.be.false;
1779
+ });
1780
+ it('preserves non-Error rejection reasons from parallel bridge execution', async () => {
1781
+ const amount = BigInt(1e18);
1782
+ const sourceInventory = BigInt(2e18);
1783
+ const route = createTestRoute({ amount });
1784
+ createTestIntent({ amount });
1785
+ inventoryRebalancer.setInventoryBalances({
1786
+ [SOLANA_CHAIN]: 0n,
1787
+ [ARBITRUM_CHAIN]: sourceInventory,
1788
+ });
1789
+ bridge.quote.onFirstCall().resolves(createMockBridgeQuote({
1790
+ fromAmount: sourceInventory,
1791
+ toAmount: sourceInventory,
1792
+ toAmountMin: sourceInventory,
1793
+ requestParams: {
1794
+ fromChain: 42161,
1795
+ toChain: 1399811149,
1796
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1797
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1798
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1799
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1800
+ fromAmount: sourceInventory,
1801
+ },
1802
+ }));
1803
+ // Simulate a foreign promise rejecting with a non-Error reason at runtime.
1804
+ const rejectionReason = 'bridge exploded';
1805
+ const executeInventoryMovementStub = Sinon.stub(inventoryRebalancer, 'executeInventoryMovement').callsFake(() => Promise.resolve().then(() => {
1806
+ throw rejectionReason;
1807
+ }));
1808
+ const results = await inventoryRebalancer.rebalance([route]);
1809
+ expect(results).to.have.lengthOf(1);
1810
+ expect(results[0].success).to.be.false;
1811
+ expect(results[0].error).to.include('All inventory movements failed');
1812
+ expect(results[0].error).to.include('arbitrum: bridge exploded');
1813
+ expect(executeInventoryMovementStub.calledOnce).to.be.true;
1814
+ expect(bridge.execute.called).to.be.false;
1815
+ executeInventoryMovementStub.restore();
1816
+ });
1817
+ it('logs actual successful bridge output floors instead of planned output totals', async () => {
1818
+ const amount = BigInt(1e18);
1819
+ const perChainInventory = BigInt(0.6e18);
1820
+ const logger = {
1821
+ info: Sinon.stub(),
1822
+ debug: Sinon.stub(),
1823
+ warn: Sinon.stub(),
1824
+ error: Sinon.stub(),
1825
+ };
1826
+ const localRebalancer = new InventoryRebalancer(config, actionTracker, { lifi: bridge }, warpCore, multiProvider, logger);
1827
+ const route = createTestRoute({ amount });
1828
+ createTestIntent({ amount });
1829
+ localRebalancer.setInventoryBalances({
1830
+ [SOLANA_CHAIN]: 0n,
1831
+ [ARBITRUM_CHAIN]: perChainInventory,
1832
+ [BASE_CHAIN]: perChainInventory,
1833
+ });
1834
+ bridge.quote
1835
+ .onCall(0)
1836
+ .resolves(createMockBridgeQuote({
1837
+ fromAmount: perChainInventory,
1838
+ toAmount: BigInt(0.525e18),
1839
+ toAmountMin: BigInt(0.525e18),
1840
+ }))
1841
+ .onCall(1)
1842
+ .resolves(createMockBridgeQuote({
1843
+ fromAmount: perChainInventory,
1844
+ toAmount: BigInt(0.525e18),
1845
+ toAmountMin: BigInt(0.525e18),
1846
+ }))
1847
+ .onCall(2)
1848
+ .resolves(createMockBridgeQuote({
1849
+ fromAmount: perChainInventory,
1850
+ toAmount: BigInt(0.505e18),
1851
+ toAmountMin: BigInt(0.5e18),
1852
+ }))
1853
+ .onCall(3)
1854
+ .resolves(createMockBridgeQuote({
1855
+ fromAmount: perChainInventory,
1856
+ toAmount: BigInt(0.515e18),
1857
+ toAmountMin: BigInt(0.51e18),
1858
+ }));
1859
+ bridge.execute
1860
+ .onFirstCall()
1861
+ .resolves({
1862
+ txHash: '0xSuccessTxHash1',
1863
+ fromChain: 42161,
1864
+ toChain: 1399811149,
1865
+ })
1866
+ .onSecondCall()
1867
+ .resolves({
1868
+ txHash: '0xSuccessTxHash2',
1869
+ fromChain: 8453,
1870
+ toChain: 1399811149,
1871
+ });
1872
+ const results = await localRebalancer.rebalance([route]);
1873
+ expect(results).to.have.lengthOf(1);
1874
+ expect(results[0].success).to.be.true;
1875
+ expect(bridge.execute.callCount).to.equal(2);
1876
+ const summaryCall = logger.info
1877
+ .getCalls()
1878
+ .find((call) => call.args[0] &&
1879
+ typeof call.args[0] === 'object' &&
1880
+ Object.prototype.hasOwnProperty.call(call.args[0], 'totalQuotedOutputMin'));
1881
+ assert(summaryCall, 'Expected summary log with totalQuotedOutputMin');
1882
+ expect(summaryCall.args[0].totalQuotedOutputMin).to.equal(BigInt(1.01e18).toString());
1550
1883
  });
1551
1884
  it('continues when some bridges fail', async () => {
1552
1885
  const amount = BigInt(1e18);
@@ -1763,7 +2096,9 @@ describe('InventoryRebalancer E2E', () => {
1763
2096
  expect(results).to.have.lengthOf(1);
1764
2097
  expect(results[0].success).to.be.true;
1765
2098
  expect(bridge.execute.calledOnce).to.be.true;
1766
- expect(bridge.quote.getCall(1)?.args[0].toAmount).to.equal(tokenBalance);
2099
+ expect(bridge.quote.callCount).to.equal(2);
2100
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(tokenBalance);
2101
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
1767
2102
  });
1768
2103
  it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
1769
2104
  // Setup: Native token with enough balance for viable bridge
@@ -1838,8 +2173,9 @@ describe('InventoryRebalancer E2E', () => {
1838
2173
  expect(results).to.have.lengthOf(1);
1839
2174
  expect(results[0].success).to.be.true;
1840
2175
  expect(bridge.execute.calledOnce).to.be.true;
1841
- expect(bridge.quote.getCall(1)?.args[0].fromAmount).to.equal(maxViable);
1842
- const executionTargetOutput = bridge.quote.getCall(2)?.args[0].toAmount;
2176
+ expect(bridge.quote.callCount).to.equal(3);
2177
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(maxViable);
2178
+ const executionTargetOutput = bridge.quote.getCall(2).args[0].toAmount;
1843
2179
  expect(executionTargetOutput).to.be.a('bigint');
1844
2180
  if (executionTargetOutput === undefined) {
1845
2181
  throw new Error('Expected reverse quote to set toAmount');
@@ -1847,6 +2183,85 @@ describe('InventoryRebalancer E2E', () => {
1847
2183
  expect(executionTargetOutput > amount).to.be.true;
1848
2184
  expect(executionTargetOutput <= maxViable).to.be.true;
1849
2185
  });
2186
+ it('uses forward quote when target output exactly matches source capacity', async () => {
2187
+ const amount = 1000n;
2188
+ const rawBalance = 1100n;
2189
+ const targetWithBuffer = 1050n;
2190
+ const route = createTestRoute({ amount });
2191
+ createTestIntent({ amount });
2192
+ inventoryRebalancer.setInventoryBalances({
2193
+ [SOLANA_CHAIN]: 0n,
2194
+ [ARBITRUM_CHAIN]: rawBalance,
2195
+ });
2196
+ bridge.quote
2197
+ .onFirstCall()
2198
+ .resolves(createMockBridgeQuote({
2199
+ fromAmount: rawBalance,
2200
+ toAmount: targetWithBuffer,
2201
+ toAmountMin: targetWithBuffer,
2202
+ }))
2203
+ .onSecondCall()
2204
+ .resolves(createMockBridgeQuote({
2205
+ fromAmount: rawBalance,
2206
+ toAmount: targetWithBuffer,
2207
+ toAmountMin: targetWithBuffer,
2208
+ }));
2209
+ bridge.execute.resolves({
2210
+ txHash: '0xForwardBoundaryBridgeTxHash',
2211
+ fromChain: 42161,
2212
+ toChain: 1399811149,
2213
+ });
2214
+ const results = await inventoryRebalancer.rebalance([route]);
2215
+ expect(results).to.have.lengthOf(1);
2216
+ expect(results[0].success).to.be.true;
2217
+ expect(bridge.execute.calledOnce).to.be.true;
2218
+ expect(bridge.quote.callCount).to.equal(2);
2219
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(rawBalance);
2220
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
2221
+ });
2222
+ it('retries reverse quote as forward when exact-output exceeds source capacity', async () => {
2223
+ const amount = 1000n;
2224
+ const rawBalance = 1100n;
2225
+ const targetWithBuffer = 1050n;
2226
+ const route = createTestRoute({ amount });
2227
+ createTestIntent({ amount });
2228
+ inventoryRebalancer.setInventoryBalances({
2229
+ [SOLANA_CHAIN]: 0n,
2230
+ [ARBITRUM_CHAIN]: rawBalance,
2231
+ });
2232
+ bridge.quote
2233
+ .onFirstCall()
2234
+ .resolves(createMockBridgeQuote({
2235
+ fromAmount: rawBalance,
2236
+ toAmount: 1051n,
2237
+ toAmountMin: 1051n,
2238
+ }))
2239
+ .onSecondCall()
2240
+ .resolves(createMockBridgeQuote({
2241
+ fromAmount: rawBalance + 1n,
2242
+ toAmount: targetWithBuffer,
2243
+ toAmountMin: targetWithBuffer,
2244
+ }))
2245
+ .onThirdCall()
2246
+ .resolves(createMockBridgeQuote({
2247
+ fromAmount: rawBalance,
2248
+ toAmount: 1049n,
2249
+ toAmountMin: 1049n,
2250
+ }));
2251
+ bridge.execute.resolves({
2252
+ txHash: '0xForwardFallbackBridgeTxHash',
2253
+ fromChain: 42161,
2254
+ toChain: 1399811149,
2255
+ });
2256
+ const results = await inventoryRebalancer.rebalance([route]);
2257
+ expect(results).to.have.lengthOf(1);
2258
+ expect(results[0].success).to.be.true;
2259
+ expect(bridge.execute.calledOnce).to.be.true;
2260
+ expect(bridge.quote.callCount).to.equal(3);
2261
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
2262
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(rawBalance);
2263
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
2264
+ });
1850
2265
  it('handles quote failures gracefully by skipping the source chain', async () => {
1851
2266
  // Setup: Native token where quote fails
1852
2267
  const arbitrumToken = {