@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.
@@ -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';
@@ -1700,24 +1701,34 @@ describe('InventoryRebalancer E2E', () => {
1700
1701
  expect(results[0].success).to.be.true;
1701
1702
  expect(bridge.execute.callCount).to.equal(2);
1702
1703
 
1703
- const reverseQuoteRequests = bridge.quote
1704
+ const maxPerSourceOutput = perChainInventory - reservedGas;
1705
+ const executionQuoteRequests = bridge.quote
1704
1706
  .getCalls()
1705
- .map((call) => call.args[0])
1706
- .filter((params) => params.toAmount !== undefined);
1707
- expect(reverseQuoteRequests).to.have.lengthOf(2);
1707
+ .slice(-2)
1708
+ .map((call) => call.args[0]);
1709
+ expect(executionQuoteRequests).to.have.lengthOf(2);
1708
1710
 
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;
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;
1721
1732
  });
1722
1733
 
1723
1734
  it('applies 5% buffer to total bridge amount', async () => {
@@ -1851,7 +1862,94 @@ describe('InventoryRebalancer E2E', () => {
1851
1862
  expect(quotedTargetOutput).to.equal(1_050_000_000_000_000_000n);
1852
1863
  });
1853
1864
 
1854
- it('fails bridge execution when reverse quote exceeds planned source capacity', async () => {
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 () => {
1855
1953
  const amount = BigInt(1e18);
1856
1954
  const sourceInventory = BigInt(2e18);
1857
1955
  const targetWithBuffer = (amount * 105n) / 100n;
@@ -1898,6 +1996,23 @@ describe('InventoryRebalancer E2E', () => {
1898
1996
  toAmount: targetWithBuffer,
1899
1997
  },
1900
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
+ }),
1901
2016
  );
1902
2017
 
1903
2018
  const results = await inventoryRebalancer.rebalance([route]);
@@ -1906,7 +2021,213 @@ describe('InventoryRebalancer E2E', () => {
1906
2021
  expect(results[0].success).to.be.false;
1907
2022
  expect(results[0].error).to.include('All inventory movements failed');
1908
2023
  expect(results[0].error).to.include('exceeded planned source capacity');
2024
+ expect(bridge.quote.callCount).to.equal(3);
1909
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
+ );
1910
2231
  });
1911
2232
 
1912
2233
  it('continues when some bridges fail', async () => {
@@ -2177,7 +2498,9 @@ describe('InventoryRebalancer E2E', () => {
2177
2498
  expect(results).to.have.lengthOf(1);
2178
2499
  expect(results[0].success).to.be.true;
2179
2500
  expect(bridge.execute.calledOnce).to.be.true;
2180
- expect(bridge.quote.getCall(1)?.args[0].toAmount).to.equal(tokenBalance);
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;
2181
2504
  });
2182
2505
 
2183
2506
  it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
@@ -2267,9 +2590,10 @@ describe('InventoryRebalancer E2E', () => {
2267
2590
  expect(results).to.have.lengthOf(1);
2268
2591
  expect(results[0].success).to.be.true;
2269
2592
  expect(bridge.execute.calledOnce).to.be.true;
2593
+ expect(bridge.quote.callCount).to.equal(3);
2270
2594
 
2271
- expect(bridge.quote.getCall(1)?.args[0].fromAmount).to.equal(maxViable);
2272
- const executionTargetOutput = bridge.quote.getCall(2)?.args[0].toAmount;
2595
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(maxViable);
2596
+ const executionTargetOutput = bridge.quote.getCall(2).args[0].toAmount;
2273
2597
  expect(executionTargetOutput).to.be.a('bigint');
2274
2598
  if (executionTargetOutput === undefined) {
2275
2599
  throw new Error('Expected reverse quote to set toAmount');
@@ -2278,6 +2602,111 @@ describe('InventoryRebalancer E2E', () => {
2278
2602
  expect(executionTargetOutput <= maxViable).to.be.true;
2279
2603
  });
2280
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
+ });
2651
+
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
+ }),
2689
+ );
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;
2708
+ });
2709
+
2281
2710
  it('handles quote failures gracefully by skipping the source chain', async () => {
2282
2711
  // Setup: Native token where quote fails
2283
2712
  const arbitrumToken = {