@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
@@ -1,18 +1,20 @@
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
 
7
8
  import {
8
9
  type ChainName,
10
+ HyperlaneCore,
9
11
  type MultiProvider,
10
12
  ProviderType,
11
13
  TokenStandard,
12
14
  WarpTxCategory,
13
15
  type WarpCore,
14
16
  } from '@hyperlane-xyz/sdk';
15
- import { ProtocolType } from '@hyperlane-xyz/utils';
17
+ import { assert, ProtocolType } from '@hyperlane-xyz/utils';
16
18
 
17
19
  import { ExternalBridgeType } from '../config/types.js';
18
20
  import type { IExternalBridge } from '../interfaces/IExternalBridge.js';
@@ -443,6 +445,165 @@ describe('InventoryRebalancer E2E', () => {
443
445
  });
444
446
  });
445
447
 
448
+ it('sendAndConfirmInventoryTx uses the EVM-like signer path for Tron txs', async () => {
449
+ const TRON_CHAIN = 'tron' as ChainName;
450
+ config.inventorySigners[ProtocolType.Tron] = {
451
+ address: INVENTORY_SIGNER,
452
+ key: TEST_PRIVATE_KEY,
453
+ };
454
+
455
+ const getChainMetadata = (chain: ChainName) => ({
456
+ name: chain,
457
+ protocol:
458
+ chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
459
+ blocks: { reorgPeriod: 2 },
460
+ rpcUrls: [{ http: 'http://127.0.0.1:9090/jsonrpc' }],
461
+ });
462
+ warpCore.multiProvider.getChainMetadata.callsFake(getChainMetadata);
463
+ multiProvider.getChainMetadata.callsFake(getChainMetadata);
464
+ warpCore.multiProvider.toMultiProvider =
465
+ Sinon.stub().returns(multiProvider);
466
+
467
+ const sendFn = (inventoryRebalancer as any).sendAndConfirmInventoryTx.bind(
468
+ inventoryRebalancer,
469
+ );
470
+ const result = await sendFn(TRON_CHAIN, {
471
+ category: WarpTxCategory.Transfer,
472
+ type: ProviderType.Tron,
473
+ transaction: {
474
+ to: '0xRouterAddress',
475
+ data: '0xTransferRemoteData',
476
+ value: 1000000n,
477
+ },
478
+ });
479
+
480
+ expect(result).to.deep.equal({ txHash: '0xTransferRemoteTxHash' });
481
+ expect(multiProvider.setSigner.calledOnce).to.be.true;
482
+ expect(
483
+ multiProvider.sendTransaction.calledOnceWithExactly(
484
+ TRON_CHAIN,
485
+ {
486
+ to: '0xRouterAddress',
487
+ data: '0xTransferRemoteData',
488
+ value: 1000000n,
489
+ },
490
+ { waitConfirmations: 2 },
491
+ ),
492
+ ).to.be.true;
493
+ });
494
+
495
+ it('buildSignerAccountConfig returns correct config for Tron protocol', () => {
496
+ config.inventorySigners[ProtocolType.Tron] = {
497
+ address: INVENTORY_SIGNER,
498
+ key: TEST_PRIVATE_KEY,
499
+ };
500
+
501
+ const buildFn = (inventoryRebalancer as any).buildSignerAccountConfig.bind(
502
+ inventoryRebalancer,
503
+ );
504
+ const result = buildFn(
505
+ ProtocolType.Tron,
506
+ TEST_PRIVATE_KEY,
507
+ 'tron' as ChainName,
508
+ );
509
+
510
+ expect(result.protocol).to.equal(ProtocolType.Tron);
511
+ expect(result.privateKey).to.equal(TEST_PRIVATE_KEY);
512
+ // Tron uses same 0x-prefixed hex key format as Ethereum
513
+ expect(result.privateKey.startsWith('0x')).to.be.true;
514
+ });
515
+
516
+ it('getTransactionReceipt returns EthersV5 type for Tron chain', async () => {
517
+ const TRON_CHAIN = 'tron' as ChainName;
518
+ const mockReceipt = {
519
+ transactionHash: '0xTronReceipt',
520
+ logs: [],
521
+ };
522
+
523
+ warpCore.multiProvider.getChainMetadata.callsFake((chain: ChainName) => ({
524
+ name: chain,
525
+ protocol:
526
+ chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
527
+ }));
528
+
529
+ warpCore.multiProvider.getEthersV5Provider.returns({
530
+ getTransactionReceipt: Sinon.stub().resolves(mockReceipt),
531
+ });
532
+
533
+ const getReceiptFn = (
534
+ inventoryRebalancer as any
535
+ ).getTransactionReceipt.bind(inventoryRebalancer);
536
+ const receipt = await getReceiptFn(TRON_CHAIN, '0xTronTxHash');
537
+
538
+ expect(receipt).to.not.be.undefined;
539
+ expect(receipt.type).to.equal(ProviderType.EthersV5);
540
+ expect(receipt.receipt).to.deep.equal(mockReceipt);
541
+ });
542
+
543
+ it('extractDispatchedMessageId returns Tron message ids via the EthersV5 path', async () => {
544
+ const TRON_CHAIN = 'tron' as ChainName;
545
+ const expectedMessageId =
546
+ '0x1111111111111111111111111111111111111111111111111111111111111111';
547
+ const mockReceipt = {
548
+ transactionHash: '0xTronTx',
549
+ logs: [{}],
550
+ };
551
+
552
+ warpCore.multiProvider.getChainMetadata.callsFake((chain: ChainName) => ({
553
+ name: chain,
554
+ protocol:
555
+ chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
556
+ }));
557
+
558
+ const getDispatchedMessagesStub = Sinon.stub(
559
+ HyperlaneCore,
560
+ 'getDispatchedMessages',
561
+ ).returns([{ id: expectedMessageId }] as any);
562
+ warpCore.multiProvider.getEthersV5Provider.returns({
563
+ getTransactionReceipt: Sinon.stub().resolves(mockReceipt),
564
+ });
565
+
566
+ const extractFn = (
567
+ inventoryRebalancer as any
568
+ ).extractDispatchedMessageId.bind(inventoryRebalancer);
569
+ const messageId = await extractFn(TRON_CHAIN, '0xTronTx');
570
+ expect(messageId).to.equal(expectedMessageId);
571
+ expect(getDispatchedMessagesStub.calledOnce).to.be.true;
572
+ expect(getDispatchedMessagesStub.firstCall.firstArg).to.equal(mockReceipt);
573
+ });
574
+
575
+ it('selects the Tron signer address from a mixed multi-protocol config', () => {
576
+ config.inventorySigners[ProtocolType.Tron] = {
577
+ address: INVENTORY_SIGNER,
578
+ key: TEST_PRIVATE_KEY,
579
+ };
580
+ config.inventorySigners[ProtocolType.Sealevel] = {
581
+ address: 'SoLANAAddReSs1234567890123456789012345678',
582
+ 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',
583
+ };
584
+
585
+ warpCore.multiProvider.getChainMetadata.callsFake((chain: ChainName) => ({
586
+ name: chain,
587
+ protocol:
588
+ chain === 'tron'
589
+ ? ProtocolType.Tron
590
+ : chain === SOLANA_CHAIN
591
+ ? ProtocolType.Sealevel
592
+ : ProtocolType.Ethereum,
593
+ }));
594
+
595
+ const getInventorySignerAddress = (
596
+ inventoryRebalancer as any
597
+ ).getInventorySignerAddress.bind(inventoryRebalancer);
598
+
599
+ expect(getInventorySignerAddress('tron' as ChainName)).to.equal(
600
+ INVENTORY_SIGNER,
601
+ );
602
+ expect(getInventorySignerAddress(SOLANA_CHAIN)).to.equal(
603
+ 'SoLANAAddReSs1234567890123456789012345678',
604
+ );
605
+ });
606
+
446
607
  describe('Partial Fulfillment (Insufficient Inventory)', () => {
447
608
  // Partial transfers happen when maxTransferable >= minViableTransfer.
448
609
  // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
@@ -1700,24 +1861,34 @@ describe('InventoryRebalancer E2E', () => {
1700
1861
  expect(results[0].success).to.be.true;
1701
1862
  expect(bridge.execute.callCount).to.equal(2);
1702
1863
 
1703
- const reverseQuoteRequests = bridge.quote
1864
+ const maxPerSourceOutput = perChainInventory - reservedGas;
1865
+ const executionQuoteRequests = bridge.quote
1704
1866
  .getCalls()
1705
- .map((call) => call.args[0])
1706
- .filter((params) => params.toAmount !== undefined);
1707
- expect(reverseQuoteRequests).to.have.lengthOf(2);
1867
+ .slice(-2)
1868
+ .map((call) => call.args[0]);
1869
+ expect(executionQuoteRequests).to.have.lengthOf(2);
1708
1870
 
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;
1871
+ const forwardQuoteRequests = executionQuoteRequests.filter(
1872
+ (params) => params.fromAmount !== undefined,
1873
+ );
1874
+ const reverseQuoteRequests = executionQuoteRequests.filter(
1875
+ (params) => params.toAmount !== undefined,
1876
+ );
1877
+ const forwardAmounts = forwardQuoteRequests.map(
1878
+ (params) => params.fromAmount as bigint,
1879
+ );
1880
+
1881
+ expect(
1882
+ forwardQuoteRequests.length + reverseQuoteRequests.length,
1883
+ ).to.equal(2);
1884
+ expect(forwardQuoteRequests.length).to.be.greaterThan(0);
1885
+ expect(forwardAmounts.every((amount) => amount <= maxPerSourceOutput)).to
1886
+ .be.true;
1887
+ expect(
1888
+ reverseQuoteRequests.every(
1889
+ (params) => (params.toAmount as bigint) <= maxPerSourceOutput,
1890
+ ),
1891
+ ).to.be.true;
1721
1892
  });
1722
1893
 
1723
1894
  it('applies 5% buffer to total bridge amount', async () => {
@@ -1851,7 +2022,94 @@ describe('InventoryRebalancer E2E', () => {
1851
2022
  expect(quotedTargetOutput).to.equal(1_050_000_000_000_000_000n);
1852
2023
  });
1853
2024
 
1854
- it('fails bridge execution when reverse quote exceeds planned source capacity', async () => {
2025
+ it('retries forward execution when reverse quote exceeds planned source capacity', async () => {
2026
+ const amount = BigInt(1e18);
2027
+ const sourceInventory = BigInt(2e18);
2028
+ const targetWithBuffer = (amount * 105n) / 100n;
2029
+
2030
+ const route = createTestRoute({ amount });
2031
+ createTestIntent({ amount });
2032
+
2033
+ inventoryRebalancer.setInventoryBalances({
2034
+ [SOLANA_CHAIN]: 0n,
2035
+ [ARBITRUM_CHAIN]: sourceInventory,
2036
+ });
2037
+
2038
+ bridge.quote
2039
+ .onFirstCall()
2040
+ .resolves(
2041
+ createMockBridgeQuote({
2042
+ fromAmount: sourceInventory,
2043
+ toAmount: sourceInventory,
2044
+ toAmountMin: sourceInventory,
2045
+ requestParams: {
2046
+ fromChain: 42161,
2047
+ toChain: 1399811149,
2048
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2049
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2050
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2051
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2052
+ fromAmount: sourceInventory,
2053
+ },
2054
+ }),
2055
+ )
2056
+ .onSecondCall()
2057
+ .resolves(
2058
+ createMockBridgeQuote({
2059
+ fromAmount: sourceInventory + 1n,
2060
+ toAmount: targetWithBuffer,
2061
+ toAmountMin: targetWithBuffer,
2062
+ requestParams: {
2063
+ fromChain: 42161,
2064
+ toChain: 1399811149,
2065
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2066
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2067
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2068
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2069
+ toAmount: targetWithBuffer,
2070
+ },
2071
+ }),
2072
+ )
2073
+ .onThirdCall()
2074
+ .resolves(
2075
+ createMockBridgeQuote({
2076
+ fromAmount: sourceInventory,
2077
+ toAmount: targetWithBuffer - 1n,
2078
+ toAmountMin: targetWithBuffer - 1n,
2079
+ requestParams: {
2080
+ fromChain: 42161,
2081
+ toChain: 1399811149,
2082
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2083
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2084
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2085
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2086
+ fromAmount: sourceInventory,
2087
+ },
2088
+ }),
2089
+ );
2090
+
2091
+ bridge.execute.resolves({
2092
+ txHash: '0xBridgeTxHash',
2093
+ fromChain: 42161,
2094
+ toChain: 1399811149,
2095
+ });
2096
+
2097
+ const results = await inventoryRebalancer.rebalance([route]);
2098
+
2099
+ expect(results).to.have.lengthOf(1);
2100
+ expect(results[0].success).to.be.true;
2101
+ expect(bridge.execute.calledOnce).to.be.true;
2102
+ expect(bridge.quote.callCount).to.equal(3);
2103
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
2104
+ targetWithBuffer,
2105
+ );
2106
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(
2107
+ sourceInventory,
2108
+ );
2109
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
2110
+ });
2111
+
2112
+ it('fails when forward retry still exceeds planned source capacity', async () => {
1855
2113
  const amount = BigInt(1e18);
1856
2114
  const sourceInventory = BigInt(2e18);
1857
2115
  const targetWithBuffer = (amount * 105n) / 100n;
@@ -1898,6 +2156,23 @@ describe('InventoryRebalancer E2E', () => {
1898
2156
  toAmount: targetWithBuffer,
1899
2157
  },
1900
2158
  }),
2159
+ )
2160
+ .onThirdCall()
2161
+ .resolves(
2162
+ createMockBridgeQuote({
2163
+ fromAmount: sourceInventory + 1n,
2164
+ toAmount: targetWithBuffer - 1n,
2165
+ toAmountMin: targetWithBuffer - 1n,
2166
+ requestParams: {
2167
+ fromChain: 42161,
2168
+ toChain: 1399811149,
2169
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2170
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2171
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2172
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2173
+ fromAmount: sourceInventory,
2174
+ },
2175
+ }),
1901
2176
  );
1902
2177
 
1903
2178
  const results = await inventoryRebalancer.rebalance([route]);
@@ -1906,7 +2181,213 @@ describe('InventoryRebalancer E2E', () => {
1906
2181
  expect(results[0].success).to.be.false;
1907
2182
  expect(results[0].error).to.include('All inventory movements failed');
1908
2183
  expect(results[0].error).to.include('exceeded planned source capacity');
2184
+ expect(bridge.quote.callCount).to.equal(3);
2185
+ expect(bridge.execute.called).to.be.false;
2186
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
2187
+ targetWithBuffer,
2188
+ );
2189
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(
2190
+ sourceInventory,
2191
+ );
2192
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
2193
+ });
2194
+
2195
+ it('fails gracefully when execute-time bridge quote rejects', async () => {
2196
+ const amount = BigInt(1e18);
2197
+ const sourceInventory = BigInt(2e18);
2198
+
2199
+ const route = createTestRoute({ amount });
2200
+ createTestIntent({ amount });
2201
+
2202
+ inventoryRebalancer.setInventoryBalances({
2203
+ [SOLANA_CHAIN]: 0n,
2204
+ [ARBITRUM_CHAIN]: sourceInventory,
2205
+ });
2206
+
2207
+ bridge.quote
2208
+ .onFirstCall()
2209
+ .resolves(
2210
+ createMockBridgeQuote({
2211
+ fromAmount: sourceInventory,
2212
+ toAmount: sourceInventory,
2213
+ toAmountMin: sourceInventory,
2214
+ requestParams: {
2215
+ fromChain: 42161,
2216
+ toChain: 1399811149,
2217
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2218
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2219
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2220
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2221
+ fromAmount: sourceInventory,
2222
+ },
2223
+ }),
2224
+ )
2225
+ .onSecondCall()
2226
+ .rejects(new Error('LiFi API timeout'));
2227
+
2228
+ const results = await inventoryRebalancer.rebalance([route]);
2229
+
2230
+ expect(results).to.have.lengthOf(1);
2231
+ expect(results[0].success).to.be.false;
2232
+ expect(results[0].error).to.include('All inventory movements failed');
2233
+ expect(results[0].error).to.include('LiFi API timeout');
2234
+ expect(bridge.quote.callCount).to.equal(2);
2235
+ expect(bridge.execute.called).to.be.false;
2236
+ });
2237
+
2238
+ it('preserves non-Error rejection reasons from parallel bridge execution', async () => {
2239
+ const amount = BigInt(1e18);
2240
+ const sourceInventory = BigInt(2e18);
2241
+
2242
+ const route = createTestRoute({ amount });
2243
+ createTestIntent({ amount });
2244
+
2245
+ inventoryRebalancer.setInventoryBalances({
2246
+ [SOLANA_CHAIN]: 0n,
2247
+ [ARBITRUM_CHAIN]: sourceInventory,
2248
+ });
2249
+
2250
+ bridge.quote.onFirstCall().resolves(
2251
+ createMockBridgeQuote({
2252
+ fromAmount: sourceInventory,
2253
+ toAmount: sourceInventory,
2254
+ toAmountMin: sourceInventory,
2255
+ requestParams: {
2256
+ fromChain: 42161,
2257
+ toChain: 1399811149,
2258
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2259
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2260
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2261
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2262
+ fromAmount: sourceInventory,
2263
+ },
2264
+ }),
2265
+ );
2266
+
2267
+ // Simulate a foreign promise rejecting with a non-Error reason at runtime.
2268
+ const rejectionReason = 'bridge exploded' as unknown as Error;
2269
+
2270
+ const executeInventoryMovementStub = Sinon.stub(
2271
+ inventoryRebalancer as unknown as {
2272
+ executeInventoryMovement: () => Promise<unknown>;
2273
+ },
2274
+ 'executeInventoryMovement',
2275
+ ).callsFake(() =>
2276
+ Promise.resolve().then(() => {
2277
+ throw rejectionReason;
2278
+ }),
2279
+ );
2280
+
2281
+ const results = await inventoryRebalancer.rebalance([route]);
2282
+
2283
+ expect(results).to.have.lengthOf(1);
2284
+ expect(results[0].success).to.be.false;
2285
+ expect(results[0].error).to.include('All inventory movements failed');
2286
+ expect(results[0].error).to.include('arbitrum: bridge exploded');
2287
+ expect(executeInventoryMovementStub.calledOnce).to.be.true;
1909
2288
  expect(bridge.execute.called).to.be.false;
2289
+
2290
+ executeInventoryMovementStub.restore();
2291
+ });
2292
+
2293
+ it('logs actual successful bridge output floors instead of planned output totals', async () => {
2294
+ const amount = BigInt(1e18);
2295
+ const perChainInventory = BigInt(0.6e18);
2296
+ const logger = {
2297
+ info: Sinon.stub(),
2298
+ debug: Sinon.stub(),
2299
+ warn: Sinon.stub(),
2300
+ error: Sinon.stub(),
2301
+ };
2302
+
2303
+ const localRebalancer = new InventoryRebalancer(
2304
+ config,
2305
+ actionTracker as unknown as IActionTracker,
2306
+ { lifi: bridge as unknown as IExternalBridge },
2307
+ warpCore as unknown as WarpCore,
2308
+ multiProvider as unknown as MultiProvider,
2309
+ logger as unknown as Logger,
2310
+ );
2311
+
2312
+ const route = createTestRoute({ amount });
2313
+ createTestIntent({ amount });
2314
+
2315
+ localRebalancer.setInventoryBalances({
2316
+ [SOLANA_CHAIN]: 0n,
2317
+ [ARBITRUM_CHAIN]: perChainInventory,
2318
+ [BASE_CHAIN]: perChainInventory,
2319
+ });
2320
+
2321
+ bridge.quote
2322
+ .onCall(0)
2323
+ .resolves(
2324
+ createMockBridgeQuote({
2325
+ fromAmount: perChainInventory,
2326
+ toAmount: BigInt(0.525e18),
2327
+ toAmountMin: BigInt(0.525e18),
2328
+ }),
2329
+ )
2330
+ .onCall(1)
2331
+ .resolves(
2332
+ createMockBridgeQuote({
2333
+ fromAmount: perChainInventory,
2334
+ toAmount: BigInt(0.525e18),
2335
+ toAmountMin: BigInt(0.525e18),
2336
+ }),
2337
+ )
2338
+ .onCall(2)
2339
+ .resolves(
2340
+ createMockBridgeQuote({
2341
+ fromAmount: perChainInventory,
2342
+ toAmount: BigInt(0.505e18),
2343
+ toAmountMin: BigInt(0.5e18),
2344
+ }),
2345
+ )
2346
+ .onCall(3)
2347
+ .resolves(
2348
+ createMockBridgeQuote({
2349
+ fromAmount: perChainInventory,
2350
+ toAmount: BigInt(0.515e18),
2351
+ toAmountMin: BigInt(0.51e18),
2352
+ }),
2353
+ );
2354
+
2355
+ bridge.execute
2356
+ .onFirstCall()
2357
+ .resolves({
2358
+ txHash: '0xSuccessTxHash1',
2359
+ fromChain: 42161,
2360
+ toChain: 1399811149,
2361
+ })
2362
+ .onSecondCall()
2363
+ .resolves({
2364
+ txHash: '0xSuccessTxHash2',
2365
+ fromChain: 8453,
2366
+ toChain: 1399811149,
2367
+ });
2368
+
2369
+ const results = await localRebalancer.rebalance([route]);
2370
+
2371
+ expect(results).to.have.lengthOf(1);
2372
+ expect(results[0].success).to.be.true;
2373
+ expect(bridge.execute.callCount).to.equal(2);
2374
+
2375
+ const summaryCall = logger.info
2376
+ .getCalls()
2377
+ .find(
2378
+ (call: ReturnType<typeof logger.info.getCall>) =>
2379
+ call.args[0] &&
2380
+ typeof call.args[0] === 'object' &&
2381
+ Object.prototype.hasOwnProperty.call(
2382
+ call.args[0],
2383
+ 'totalQuotedOutputMin',
2384
+ ),
2385
+ );
2386
+
2387
+ assert(summaryCall, 'Expected summary log with totalQuotedOutputMin');
2388
+ expect(summaryCall.args[0].totalQuotedOutputMin).to.equal(
2389
+ BigInt(1.01e18).toString(),
2390
+ );
1910
2391
  });
1911
2392
 
1912
2393
  it('continues when some bridges fail', async () => {
@@ -2177,7 +2658,9 @@ describe('InventoryRebalancer E2E', () => {
2177
2658
  expect(results).to.have.lengthOf(1);
2178
2659
  expect(results[0].success).to.be.true;
2179
2660
  expect(bridge.execute.calledOnce).to.be.true;
2180
- expect(bridge.quote.getCall(1)?.args[0].toAmount).to.equal(tokenBalance);
2661
+ expect(bridge.quote.callCount).to.equal(2);
2662
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(tokenBalance);
2663
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
2181
2664
  });
2182
2665
 
2183
2666
  it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
@@ -2267,9 +2750,10 @@ describe('InventoryRebalancer E2E', () => {
2267
2750
  expect(results).to.have.lengthOf(1);
2268
2751
  expect(results[0].success).to.be.true;
2269
2752
  expect(bridge.execute.calledOnce).to.be.true;
2753
+ expect(bridge.quote.callCount).to.equal(3);
2270
2754
 
2271
- expect(bridge.quote.getCall(1)?.args[0].fromAmount).to.equal(maxViable);
2272
- const executionTargetOutput = bridge.quote.getCall(2)?.args[0].toAmount;
2755
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(maxViable);
2756
+ const executionTargetOutput = bridge.quote.getCall(2).args[0].toAmount;
2273
2757
  expect(executionTargetOutput).to.be.a('bigint');
2274
2758
  if (executionTargetOutput === undefined) {
2275
2759
  throw new Error('Expected reverse quote to set toAmount');
@@ -2278,6 +2762,111 @@ describe('InventoryRebalancer E2E', () => {
2278
2762
  expect(executionTargetOutput <= maxViable).to.be.true;
2279
2763
  });
2280
2764
 
2765
+ it('uses forward quote when target output exactly matches source capacity', async () => {
2766
+ const amount = 1000n;
2767
+ const rawBalance = 1100n;
2768
+ const targetWithBuffer = 1050n;
2769
+
2770
+ const route = createTestRoute({ amount });
2771
+ createTestIntent({ amount });
2772
+
2773
+ inventoryRebalancer.setInventoryBalances({
2774
+ [SOLANA_CHAIN]: 0n,
2775
+ [ARBITRUM_CHAIN]: rawBalance,
2776
+ });
2777
+
2778
+ bridge.quote
2779
+ .onFirstCall()
2780
+ .resolves(
2781
+ createMockBridgeQuote({
2782
+ fromAmount: rawBalance,
2783
+ toAmount: targetWithBuffer,
2784
+ toAmountMin: targetWithBuffer,
2785
+ }),
2786
+ )
2787
+ .onSecondCall()
2788
+ .resolves(
2789
+ createMockBridgeQuote({
2790
+ fromAmount: rawBalance,
2791
+ toAmount: targetWithBuffer,
2792
+ toAmountMin: targetWithBuffer,
2793
+ }),
2794
+ );
2795
+
2796
+ bridge.execute.resolves({
2797
+ txHash: '0xForwardBoundaryBridgeTxHash',
2798
+ fromChain: 42161,
2799
+ toChain: 1399811149,
2800
+ });
2801
+
2802
+ const results = await inventoryRebalancer.rebalance([route]);
2803
+
2804
+ expect(results).to.have.lengthOf(1);
2805
+ expect(results[0].success).to.be.true;
2806
+ expect(bridge.execute.calledOnce).to.be.true;
2807
+ expect(bridge.quote.callCount).to.equal(2);
2808
+ expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(rawBalance);
2809
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
2810
+ });
2811
+
2812
+ it('retries reverse quote as forward when exact-output exceeds source capacity', async () => {
2813
+ const amount = 1000n;
2814
+ const rawBalance = 1100n;
2815
+ const targetWithBuffer = 1050n;
2816
+
2817
+ const route = createTestRoute({ amount });
2818
+ createTestIntent({ amount });
2819
+
2820
+ inventoryRebalancer.setInventoryBalances({
2821
+ [SOLANA_CHAIN]: 0n,
2822
+ [ARBITRUM_CHAIN]: rawBalance,
2823
+ });
2824
+
2825
+ bridge.quote
2826
+ .onFirstCall()
2827
+ .resolves(
2828
+ createMockBridgeQuote({
2829
+ fromAmount: rawBalance,
2830
+ toAmount: 1051n,
2831
+ toAmountMin: 1051n,
2832
+ }),
2833
+ )
2834
+ .onSecondCall()
2835
+ .resolves(
2836
+ createMockBridgeQuote({
2837
+ fromAmount: rawBalance + 1n,
2838
+ toAmount: targetWithBuffer,
2839
+ toAmountMin: targetWithBuffer,
2840
+ }),
2841
+ )
2842
+ .onThirdCall()
2843
+ .resolves(
2844
+ createMockBridgeQuote({
2845
+ fromAmount: rawBalance,
2846
+ toAmount: 1049n,
2847
+ toAmountMin: 1049n,
2848
+ }),
2849
+ );
2850
+
2851
+ bridge.execute.resolves({
2852
+ txHash: '0xForwardFallbackBridgeTxHash',
2853
+ fromChain: 42161,
2854
+ toChain: 1399811149,
2855
+ });
2856
+
2857
+ const results = await inventoryRebalancer.rebalance([route]);
2858
+
2859
+ expect(results).to.have.lengthOf(1);
2860
+ expect(results[0].success).to.be.true;
2861
+ expect(bridge.execute.calledOnce).to.be.true;
2862
+ expect(bridge.quote.callCount).to.equal(3);
2863
+ expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
2864
+ targetWithBuffer,
2865
+ );
2866
+ expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(rawBalance);
2867
+ expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
2868
+ });
2869
+
2281
2870
  it('handles quote failures gracefully by skipping the source chain', async () => {
2282
2871
  // Setup: Native token where quote fails
2283
2872
  const arbitrumToken = {