@hyperlane-xyz/rebalancer 27.2.13 → 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.
@@ -4,7 +4,7 @@ import { Wallet } from 'ethers';
4
4
  import { pino } from 'pino';
5
5
  import Sinon from 'sinon';
6
6
  import { ProviderType, TokenStandard, WarpTxCategory, } from '@hyperlane-xyz/sdk';
7
- import { ProtocolType } from '@hyperlane-xyz/utils';
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';
@@ -1373,22 +1373,20 @@ describe('InventoryRebalancer E2E', () => {
1373
1373
  expect(results).to.have.lengthOf(1);
1374
1374
  expect(results[0].success).to.be.true;
1375
1375
  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
1376
  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;
1377
+ const executionQuoteRequests = bridge.quote
1378
+ .getCalls()
1379
+ .slice(-2)
1380
+ .map((call) => call.args[0]);
1381
+ expect(executionQuoteRequests).to.have.lengthOf(2);
1382
+ const forwardQuoteRequests = executionQuoteRequests.filter((params) => params.fromAmount !== undefined);
1383
+ const reverseQuoteRequests = executionQuoteRequests.filter((params) => params.toAmount !== undefined);
1384
+ const forwardAmounts = forwardQuoteRequests.map((params) => params.fromAmount);
1385
+ expect(forwardQuoteRequests.length + reverseQuoteRequests.length).to.equal(2);
1386
+ expect(forwardQuoteRequests.length).to.be.greaterThan(0);
1387
+ expect(forwardAmounts.every((amount) => amount <= maxPerSourceOutput)).to
1388
+ .be.true;
1389
+ expect(reverseQuoteRequests.every((params) => params.toAmount <= maxPerSourceOutput)).to.be.true;
1392
1390
  });
1393
1391
  it('applies 5% buffer to total bridge amount', async () => {
1394
1392
  // Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
@@ -1500,7 +1498,77 @@ describe('InventoryRebalancer E2E', () => {
1500
1498
  await inventoryRebalancer.rebalance([route]);
1501
1499
  expect(quotedTargetOutput).to.equal(1050000000000000000n);
1502
1500
  });
1503
- it('fails bridge execution when reverse quote exceeds planned source capacity', async () => {
1501
+ it('retries forward execution when reverse quote exceeds planned source capacity', async () => {
1502
+ const amount = BigInt(1e18);
1503
+ const sourceInventory = BigInt(2e18);
1504
+ const targetWithBuffer = (amount * 105n) / 100n;
1505
+ const route = createTestRoute({ amount });
1506
+ createTestIntent({ amount });
1507
+ inventoryRebalancer.setInventoryBalances({
1508
+ [SOLANA_CHAIN]: 0n,
1509
+ [ARBITRUM_CHAIN]: sourceInventory,
1510
+ });
1511
+ bridge.quote
1512
+ .onFirstCall()
1513
+ .resolves(createMockBridgeQuote({
1514
+ fromAmount: sourceInventory,
1515
+ toAmount: sourceInventory,
1516
+ toAmountMin: sourceInventory,
1517
+ requestParams: {
1518
+ fromChain: 42161,
1519
+ toChain: 1399811149,
1520
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1521
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1522
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1523
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1524
+ fromAmount: sourceInventory,
1525
+ },
1526
+ }))
1527
+ .onSecondCall()
1528
+ .resolves(createMockBridgeQuote({
1529
+ fromAmount: sourceInventory + 1n,
1530
+ toAmount: targetWithBuffer,
1531
+ toAmountMin: targetWithBuffer,
1532
+ requestParams: {
1533
+ fromChain: 42161,
1534
+ toChain: 1399811149,
1535
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1536
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1537
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1538
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1539
+ toAmount: targetWithBuffer,
1540
+ },
1541
+ }))
1542
+ .onThirdCall()
1543
+ .resolves(createMockBridgeQuote({
1544
+ fromAmount: sourceInventory,
1545
+ toAmount: targetWithBuffer - 1n,
1546
+ toAmountMin: targetWithBuffer - 1n,
1547
+ requestParams: {
1548
+ fromChain: 42161,
1549
+ toChain: 1399811149,
1550
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1551
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1552
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1553
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1554
+ fromAmount: sourceInventory,
1555
+ },
1556
+ }));
1557
+ bridge.execute.resolves({
1558
+ txHash: '0xBridgeTxHash',
1559
+ fromChain: 42161,
1560
+ toChain: 1399811149,
1561
+ });
1562
+ const results = await inventoryRebalancer.rebalance([route]);
1563
+ expect(results).to.have.lengthOf(1);
1564
+ expect(results[0].success).to.be.true;
1565
+ expect(bridge.execute.calledOnce).to.be.true;
1566
+ expect(bridge.quote.callCount).to.equal(3);
1567
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
1568
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(sourceInventory);
1569
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
1570
+ });
1571
+ it('fails when forward retry still exceeds planned source capacity', async () => {
1504
1572
  const amount = BigInt(1e18);
1505
1573
  const sourceInventory = BigInt(2e18);
1506
1574
  const targetWithBuffer = (amount * 105n) / 100n;
@@ -1540,14 +1608,172 @@ describe('InventoryRebalancer E2E', () => {
1540
1608
  toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1541
1609
  toAmount: targetWithBuffer,
1542
1610
  },
1611
+ }))
1612
+ .onThirdCall()
1613
+ .resolves(createMockBridgeQuote({
1614
+ fromAmount: sourceInventory + 1n,
1615
+ toAmount: targetWithBuffer - 1n,
1616
+ toAmountMin: targetWithBuffer - 1n,
1617
+ requestParams: {
1618
+ fromChain: 42161,
1619
+ toChain: 1399811149,
1620
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1621
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1622
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1623
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1624
+ fromAmount: sourceInventory,
1625
+ },
1543
1626
  }));
1544
1627
  const results = await inventoryRebalancer.rebalance([route]);
1545
1628
  expect(results).to.have.lengthOf(1);
1546
1629
  expect(results[0].success).to.be.false;
1547
1630
  expect(results[0].error).to.include('All inventory movements failed');
1548
1631
  expect(results[0].error).to.include('exceeded planned source capacity');
1632
+ expect(bridge.quote.callCount).to.equal(3);
1633
+ expect(bridge.execute.called).to.be.false;
1634
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
1635
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(sourceInventory);
1636
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
1637
+ });
1638
+ it('fails gracefully when execute-time bridge quote rejects', async () => {
1639
+ const amount = BigInt(1e18);
1640
+ const sourceInventory = BigInt(2e18);
1641
+ const route = createTestRoute({ amount });
1642
+ createTestIntent({ amount });
1643
+ inventoryRebalancer.setInventoryBalances({
1644
+ [SOLANA_CHAIN]: 0n,
1645
+ [ARBITRUM_CHAIN]: sourceInventory,
1646
+ });
1647
+ bridge.quote
1648
+ .onFirstCall()
1649
+ .resolves(createMockBridgeQuote({
1650
+ fromAmount: sourceInventory,
1651
+ toAmount: sourceInventory,
1652
+ toAmountMin: sourceInventory,
1653
+ requestParams: {
1654
+ fromChain: 42161,
1655
+ toChain: 1399811149,
1656
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1657
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1658
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1659
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1660
+ fromAmount: sourceInventory,
1661
+ },
1662
+ }))
1663
+ .onSecondCall()
1664
+ .rejects(new Error('LiFi API timeout'));
1665
+ const results = await inventoryRebalancer.rebalance([route]);
1666
+ expect(results).to.have.lengthOf(1);
1667
+ expect(results[0].success).to.be.false;
1668
+ expect(results[0].error).to.include('All inventory movements failed');
1669
+ expect(results[0].error).to.include('LiFi API timeout');
1670
+ expect(bridge.quote.callCount).to.equal(2);
1549
1671
  expect(bridge.execute.called).to.be.false;
1550
1672
  });
1673
+ it('preserves non-Error rejection reasons from parallel bridge execution', async () => {
1674
+ const amount = BigInt(1e18);
1675
+ const sourceInventory = BigInt(2e18);
1676
+ const route = createTestRoute({ amount });
1677
+ createTestIntent({ amount });
1678
+ inventoryRebalancer.setInventoryBalances({
1679
+ [SOLANA_CHAIN]: 0n,
1680
+ [ARBITRUM_CHAIN]: sourceInventory,
1681
+ });
1682
+ bridge.quote.onFirstCall().resolves(createMockBridgeQuote({
1683
+ fromAmount: sourceInventory,
1684
+ toAmount: sourceInventory,
1685
+ toAmountMin: sourceInventory,
1686
+ requestParams: {
1687
+ fromChain: 42161,
1688
+ toChain: 1399811149,
1689
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1690
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1691
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1692
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1693
+ fromAmount: sourceInventory,
1694
+ },
1695
+ }));
1696
+ // Simulate a foreign promise rejecting with a non-Error reason at runtime.
1697
+ const rejectionReason = 'bridge exploded';
1698
+ const executeInventoryMovementStub = Sinon.stub(inventoryRebalancer, 'executeInventoryMovement').callsFake(() => Promise.resolve().then(() => {
1699
+ throw rejectionReason;
1700
+ }));
1701
+ const results = await inventoryRebalancer.rebalance([route]);
1702
+ expect(results).to.have.lengthOf(1);
1703
+ expect(results[0].success).to.be.false;
1704
+ expect(results[0].error).to.include('All inventory movements failed');
1705
+ expect(results[0].error).to.include('arbitrum: bridge exploded');
1706
+ expect(executeInventoryMovementStub.calledOnce).to.be.true;
1707
+ expect(bridge.execute.called).to.be.false;
1708
+ executeInventoryMovementStub.restore();
1709
+ });
1710
+ it('logs actual successful bridge output floors instead of planned output totals', async () => {
1711
+ const amount = BigInt(1e18);
1712
+ const perChainInventory = BigInt(0.6e18);
1713
+ const logger = {
1714
+ info: Sinon.stub(),
1715
+ debug: Sinon.stub(),
1716
+ warn: Sinon.stub(),
1717
+ error: Sinon.stub(),
1718
+ };
1719
+ const localRebalancer = new InventoryRebalancer(config, actionTracker, { lifi: bridge }, warpCore, multiProvider, logger);
1720
+ const route = createTestRoute({ amount });
1721
+ createTestIntent({ amount });
1722
+ localRebalancer.setInventoryBalances({
1723
+ [SOLANA_CHAIN]: 0n,
1724
+ [ARBITRUM_CHAIN]: perChainInventory,
1725
+ [BASE_CHAIN]: perChainInventory,
1726
+ });
1727
+ bridge.quote
1728
+ .onCall(0)
1729
+ .resolves(createMockBridgeQuote({
1730
+ fromAmount: perChainInventory,
1731
+ toAmount: BigInt(0.525e18),
1732
+ toAmountMin: BigInt(0.525e18),
1733
+ }))
1734
+ .onCall(1)
1735
+ .resolves(createMockBridgeQuote({
1736
+ fromAmount: perChainInventory,
1737
+ toAmount: BigInt(0.525e18),
1738
+ toAmountMin: BigInt(0.525e18),
1739
+ }))
1740
+ .onCall(2)
1741
+ .resolves(createMockBridgeQuote({
1742
+ fromAmount: perChainInventory,
1743
+ toAmount: BigInt(0.505e18),
1744
+ toAmountMin: BigInt(0.5e18),
1745
+ }))
1746
+ .onCall(3)
1747
+ .resolves(createMockBridgeQuote({
1748
+ fromAmount: perChainInventory,
1749
+ toAmount: BigInt(0.515e18),
1750
+ toAmountMin: BigInt(0.51e18),
1751
+ }));
1752
+ bridge.execute
1753
+ .onFirstCall()
1754
+ .resolves({
1755
+ txHash: '0xSuccessTxHash1',
1756
+ fromChain: 42161,
1757
+ toChain: 1399811149,
1758
+ })
1759
+ .onSecondCall()
1760
+ .resolves({
1761
+ txHash: '0xSuccessTxHash2',
1762
+ fromChain: 8453,
1763
+ toChain: 1399811149,
1764
+ });
1765
+ const results = await localRebalancer.rebalance([route]);
1766
+ expect(results).to.have.lengthOf(1);
1767
+ expect(results[0].success).to.be.true;
1768
+ expect(bridge.execute.callCount).to.equal(2);
1769
+ const summaryCall = logger.info
1770
+ .getCalls()
1771
+ .find((call) => call.args[0] &&
1772
+ typeof call.args[0] === 'object' &&
1773
+ Object.prototype.hasOwnProperty.call(call.args[0], 'totalQuotedOutputMin'));
1774
+ assert(summaryCall, 'Expected summary log with totalQuotedOutputMin');
1775
+ expect(summaryCall.args[0].totalQuotedOutputMin).to.equal(BigInt(1.01e18).toString());
1776
+ });
1551
1777
  it('continues when some bridges fail', async () => {
1552
1778
  const amount = BigInt(1e18);
1553
1779
  const perChainInventory = BigInt(0.6e18);
@@ -1763,7 +1989,9 @@ describe('InventoryRebalancer E2E', () => {
1763
1989
  expect(results).to.have.lengthOf(1);
1764
1990
  expect(results[0].success).to.be.true;
1765
1991
  expect(bridge.execute.calledOnce).to.be.true;
1766
- expect(bridge.quote.getCall(1)?.args[0].toAmount).to.equal(tokenBalance);
1992
+ expect(bridge.quote.callCount).to.equal(2);
1993
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(tokenBalance);
1994
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
1767
1995
  });
1768
1996
  it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
1769
1997
  // Setup: Native token with enough balance for viable bridge
@@ -1838,8 +2066,9 @@ describe('InventoryRebalancer E2E', () => {
1838
2066
  expect(results).to.have.lengthOf(1);
1839
2067
  expect(results[0].success).to.be.true;
1840
2068
  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;
2069
+ expect(bridge.quote.callCount).to.equal(3);
2070
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(maxViable);
2071
+ const executionTargetOutput = bridge.quote.getCall(2).args[0].toAmount;
1843
2072
  expect(executionTargetOutput).to.be.a('bigint');
1844
2073
  if (executionTargetOutput === undefined) {
1845
2074
  throw new Error('Expected reverse quote to set toAmount');
@@ -1847,6 +2076,85 @@ describe('InventoryRebalancer E2E', () => {
1847
2076
  expect(executionTargetOutput > amount).to.be.true;
1848
2077
  expect(executionTargetOutput <= maxViable).to.be.true;
1849
2078
  });
2079
+ it('uses forward quote when target output exactly matches source capacity', async () => {
2080
+ const amount = 1000n;
2081
+ const rawBalance = 1100n;
2082
+ const targetWithBuffer = 1050n;
2083
+ const route = createTestRoute({ amount });
2084
+ createTestIntent({ amount });
2085
+ inventoryRebalancer.setInventoryBalances({
2086
+ [SOLANA_CHAIN]: 0n,
2087
+ [ARBITRUM_CHAIN]: rawBalance,
2088
+ });
2089
+ bridge.quote
2090
+ .onFirstCall()
2091
+ .resolves(createMockBridgeQuote({
2092
+ fromAmount: rawBalance,
2093
+ toAmount: targetWithBuffer,
2094
+ toAmountMin: targetWithBuffer,
2095
+ }))
2096
+ .onSecondCall()
2097
+ .resolves(createMockBridgeQuote({
2098
+ fromAmount: rawBalance,
2099
+ toAmount: targetWithBuffer,
2100
+ toAmountMin: targetWithBuffer,
2101
+ }));
2102
+ bridge.execute.resolves({
2103
+ txHash: '0xForwardBoundaryBridgeTxHash',
2104
+ fromChain: 42161,
2105
+ toChain: 1399811149,
2106
+ });
2107
+ const results = await inventoryRebalancer.rebalance([route]);
2108
+ expect(results).to.have.lengthOf(1);
2109
+ expect(results[0].success).to.be.true;
2110
+ expect(bridge.execute.calledOnce).to.be.true;
2111
+ expect(bridge.quote.callCount).to.equal(2);
2112
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(rawBalance);
2113
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
2114
+ });
2115
+ it('retries reverse quote as forward when exact-output exceeds source capacity', async () => {
2116
+ const amount = 1000n;
2117
+ const rawBalance = 1100n;
2118
+ const targetWithBuffer = 1050n;
2119
+ const route = createTestRoute({ amount });
2120
+ createTestIntent({ amount });
2121
+ inventoryRebalancer.setInventoryBalances({
2122
+ [SOLANA_CHAIN]: 0n,
2123
+ [ARBITRUM_CHAIN]: rawBalance,
2124
+ });
2125
+ bridge.quote
2126
+ .onFirstCall()
2127
+ .resolves(createMockBridgeQuote({
2128
+ fromAmount: rawBalance,
2129
+ toAmount: 1051n,
2130
+ toAmountMin: 1051n,
2131
+ }))
2132
+ .onSecondCall()
2133
+ .resolves(createMockBridgeQuote({
2134
+ fromAmount: rawBalance + 1n,
2135
+ toAmount: targetWithBuffer,
2136
+ toAmountMin: targetWithBuffer,
2137
+ }))
2138
+ .onThirdCall()
2139
+ .resolves(createMockBridgeQuote({
2140
+ fromAmount: rawBalance,
2141
+ toAmount: 1049n,
2142
+ toAmountMin: 1049n,
2143
+ }));
2144
+ bridge.execute.resolves({
2145
+ txHash: '0xForwardFallbackBridgeTxHash',
2146
+ fromChain: 42161,
2147
+ toChain: 1399811149,
2148
+ });
2149
+ const results = await inventoryRebalancer.rebalance([route]);
2150
+ expect(results).to.have.lengthOf(1);
2151
+ expect(results[0].success).to.be.true;
2152
+ expect(bridge.execute.calledOnce).to.be.true;
2153
+ expect(bridge.quote.callCount).to.equal(3);
2154
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
2155
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(rawBalance);
2156
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
2157
+ });
1850
2158
  it('handles quote failures gracefully by skipping the source chain', async () => {
1851
2159
  // Setup: Native token where quote fails
1852
2160
  const arbitrumToken = {