@hyperlane-xyz/rebalancer 27.2.12 → 27.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/bridges/LiFiBridge.js +1 -1
  2. package/dist/bridges/LiFiBridge.js.map +1 -1
  3. package/dist/bridges/LiFiBridge.test.js +37 -0
  4. package/dist/bridges/LiFiBridge.test.js.map +1 -1
  5. package/dist/core/InventoryRebalancer.d.ts +13 -19
  6. package/dist/core/InventoryRebalancer.d.ts.map +1 -1
  7. package/dist/core/InventoryRebalancer.js +400 -274
  8. package/dist/core/InventoryRebalancer.js.map +1 -1
  9. package/dist/core/InventoryRebalancer.test.js +706 -24
  10. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  11. package/dist/core/Rebalancer.d.ts.map +1 -1
  12. package/dist/core/Rebalancer.js +12 -6
  13. package/dist/core/Rebalancer.js.map +1 -1
  14. package/dist/core/Rebalancer.test.js +51 -0
  15. package/dist/core/Rebalancer.test.js.map +1 -1
  16. package/dist/core/RebalancerOrchestrator.test.js +0 -1
  17. package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
  18. package/dist/core/RebalancerService.d.ts +2 -3
  19. package/dist/core/RebalancerService.d.ts.map +1 -1
  20. package/dist/core/RebalancerService.js +3 -2
  21. package/dist/core/RebalancerService.js.map +1 -1
  22. package/dist/core/RebalancerService.test.js +24 -0
  23. package/dist/core/RebalancerService.test.js.map +1 -1
  24. package/dist/e2e/harness/TestHelpers.js +1 -2
  25. package/dist/e2e/harness/TestHelpers.js.map +1 -1
  26. package/dist/factories/RebalancerContextFactory.d.ts +4 -5
  27. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  28. package/dist/factories/RebalancerContextFactory.js +12 -7
  29. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  30. package/dist/factories/RebalancerContextFactory.test.js +99 -2
  31. package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
  32. package/dist/interfaces/IRebalancer.d.ts +4 -2
  33. package/dist/interfaces/IRebalancer.d.ts.map +1 -1
  34. package/dist/metrics/scripts/metrics.d.ts +1 -1
  35. package/dist/monitor/Monitor.d.ts.map +1 -1
  36. package/dist/monitor/Monitor.js +14 -6
  37. package/dist/monitor/Monitor.js.map +1 -1
  38. package/dist/strategy/BaseStrategy.d.ts.map +1 -1
  39. package/dist/strategy/BaseStrategy.js +13 -11
  40. package/dist/strategy/BaseStrategy.js.map +1 -1
  41. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
  42. package/dist/strategy/CollateralDeficitStrategy.js +2 -2
  43. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
  44. package/dist/strategy/MinAmountStrategy.d.ts +1 -0
  45. package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
  46. package/dist/strategy/MinAmountStrategy.js +12 -8
  47. package/dist/strategy/MinAmountStrategy.js.map +1 -1
  48. package/dist/strategy/MinAmountStrategy.test.js +189 -2
  49. package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
  50. package/dist/test/helpers.d.ts +11 -3
  51. package/dist/test/helpers.d.ts.map +1 -1
  52. package/dist/test/helpers.js +9 -11
  53. package/dist/test/helpers.js.map +1 -1
  54. package/dist/test/lifiMocks.d.ts.map +1 -1
  55. package/dist/test/lifiMocks.js +5 -2
  56. package/dist/test/lifiMocks.js.map +1 -1
  57. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  58. package/dist/tracking/ActionTracker.js +2 -1
  59. package/dist/tracking/ActionTracker.js.map +1 -1
  60. package/dist/tracking/ActionTracker.test.js +39 -0
  61. package/dist/tracking/ActionTracker.test.js.map +1 -1
  62. package/dist/utils/balanceUtils.d.ts +7 -1
  63. package/dist/utils/balanceUtils.d.ts.map +1 -1
  64. package/dist/utils/balanceUtils.js +39 -1
  65. package/dist/utils/balanceUtils.js.map +1 -1
  66. package/dist/utils/balanceUtils.test.js +55 -1
  67. package/dist/utils/balanceUtils.test.js.map +1 -1
  68. package/dist/utils/blockTag.d.ts +3 -3
  69. package/dist/utils/blockTag.d.ts.map +1 -1
  70. package/dist/utils/blockTag.js +1 -1
  71. package/dist/utils/blockTag.js.map +1 -1
  72. package/package.json +7 -7
  73. package/src/bridges/LiFiBridge.test.ts +43 -0
  74. package/src/bridges/LiFiBridge.ts +1 -1
  75. package/src/core/InventoryRebalancer.test.ts +932 -38
  76. package/src/core/InventoryRebalancer.ts +579 -361
  77. package/src/core/Rebalancer.test.ts +84 -0
  78. package/src/core/Rebalancer.ts +22 -6
  79. package/src/core/RebalancerOrchestrator.test.ts +0 -1
  80. package/src/core/RebalancerService.test.ts +35 -0
  81. package/src/core/RebalancerService.ts +9 -5
  82. package/src/e2e/harness/TestHelpers.ts +3 -3
  83. package/src/factories/RebalancerContextFactory.test.ts +143 -6
  84. package/src/factories/RebalancerContextFactory.ts +29 -17
  85. package/src/interfaces/IRebalancer.ts +4 -1
  86. package/src/monitor/Monitor.ts +19 -6
  87. package/src/strategy/BaseStrategy.ts +18 -15
  88. package/src/strategy/CollateralDeficitStrategy.ts +4 -3
  89. package/src/strategy/MinAmountStrategy.test.ts +238 -2
  90. package/src/strategy/MinAmountStrategy.ts +29 -17
  91. package/src/test/helpers.ts +13 -12
  92. package/src/test/lifiMocks.ts +5 -2
  93. package/src/tracking/ActionTracker.test.ts +47 -0
  94. package/src/tracking/ActionTracker.ts +2 -1
  95. package/src/utils/balanceUtils.test.ts +87 -1
  96. package/src/utils/balanceUtils.ts +73 -2
  97. package/src/utils/blockTag.ts +9 -4
@@ -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';
@@ -123,6 +123,7 @@ describe('InventoryRebalancer E2E', () => {
123
123
  }),
124
124
  },
125
125
  findToken: Sinon.stub().returns(null),
126
+ getMaxTransferAmount: Sinon.stub().callsFake(async ({ balance }) => balance),
126
127
  getTransferRemoteTxs: Sinon.stub().resolves([
127
128
  {
128
129
  category: 'transfer',
@@ -230,6 +231,18 @@ describe('InventoryRebalancer E2E', () => {
230
231
  actionTracker.createRebalanceIntent.resolves(intent);
231
232
  return intent;
232
233
  }
234
+ function mockSuccessfulBridge(fromAmount, toAmount) {
235
+ bridge.quote.resolves(createMockBridgeQuote({
236
+ fromAmount,
237
+ toAmount,
238
+ toAmountMin: toAmount,
239
+ }));
240
+ bridge.execute.resolves({
241
+ txHash: '0xBridgeTxHash',
242
+ fromChain: 42161,
243
+ toChain: 1399811149,
244
+ });
245
+ }
233
246
  describe('Basic Inventory Rebalance (Sufficient Inventory)', () => {
234
247
  // NOTE: Strategy route is arbitrum (surplus) → solana (deficit)
235
248
  // But execution calls transferRemote FROM solana TO arbitrum (swapped direction)
@@ -323,10 +336,30 @@ describe('InventoryRebalancer E2E', () => {
323
336
  const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
324
337
  expect(actionParams.txHash).to.equal('0xSolanaTxHash');
325
338
  });
339
+ it('denormalizes inventory execution amounts but records canonical deposit amount', async () => {
340
+ const route = createTestRoute({ amount: 1000000n });
341
+ createTestIntent({ amount: 1000000n });
342
+ warpCore.tokens.find((t) => t.chainName === SOLANA_CHAIN).scale = {
343
+ numerator: 1n,
344
+ denominator: 1000000000000n,
345
+ };
346
+ inventoryRebalancer.setInventoryBalances({
347
+ [SOLANA_CHAIN]: 1000000000000000000n,
348
+ [ARBITRUM_CHAIN]: 0n,
349
+ });
350
+ const results = await inventoryRebalancer.rebalance([route]);
351
+ expect(results).to.have.lengthOf(1);
352
+ expect(results[0].success).to.be.true;
353
+ const txParams = warpCore.getTransferRemoteTxs.firstCall.args[0];
354
+ expect(txParams.originTokenAmount.amount).to.equal(1000000000000000000n);
355
+ const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
356
+ expect(actionParams.amount).to.equal(1000000n);
357
+ });
326
358
  });
327
359
  describe('Partial Fulfillment (Insufficient Inventory)', () => {
328
- // Partial transfers happen when maxTransferable >= minViableTransfer
329
- // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so partial always viable
360
+ // Partial transfers happen when maxTransferable >= minViableTransfer.
361
+ // For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
362
+ // positive fee-aware maxTransferable remains viable.
330
363
  const PARTIAL_AMOUNT = BigInt(5e15); // 0.005 ETH - above threshold
331
364
  const FULL_AMOUNT = BigInt(1e16); // 0.01 ETH
332
365
  it('executes partial transferRemote when maxTransferable >= minViableTransfer', async () => {
@@ -351,6 +384,49 @@ describe('InventoryRebalancer E2E', () => {
351
384
  const actionParams = actionTracker.createRebalanceAction.firstCall.args[0];
352
385
  expect(actionParams.amount).to.equal(PARTIAL_AMOUNT);
353
386
  });
387
+ it('uses fee-aware maxTransferable for non-native token fees', async () => {
388
+ const requestedAmount = 19998000000n;
389
+ const availableInventory = 102466n;
390
+ const safeTransferAmount = 102400n;
391
+ warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
392
+ const route = createTestRoute({ amount: requestedAmount });
393
+ createTestIntent({ amount: requestedAmount });
394
+ inventoryRebalancer.setInventoryBalances({
395
+ [SOLANA_CHAIN]: availableInventory,
396
+ [ARBITRUM_CHAIN]: 0n,
397
+ });
398
+ const results = await inventoryRebalancer.rebalance([route]);
399
+ expect(results).to.have.lengthOf(1);
400
+ expect(results[0].success).to.be.true;
401
+ expect(warpCore.getMaxTransferAmount.calledOnce).to.be.true;
402
+ const maxTransferArgs = warpCore.getMaxTransferAmount.firstCall.args[0];
403
+ expect(maxTransferArgs.balance.amount).to.equal(availableInventory);
404
+ expect(maxTransferArgs.destination).to.equal(ARBITRUM_CHAIN);
405
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
406
+ expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
407
+ expect(txParams).to.not.have.property('interchainFee');
408
+ expect(txParams).to.not.have.property('tokenFeeQuote');
409
+ const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
410
+ expect(actionParams.amount).to.equal(safeTransferAmount);
411
+ });
412
+ it('downgrades full non-native transfers to partial when fee headroom is missing', async () => {
413
+ const requestedAmount = 102466n;
414
+ const safeTransferAmount = 102400n;
415
+ warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
416
+ const route = createTestRoute({ amount: requestedAmount });
417
+ createTestIntent({ amount: requestedAmount });
418
+ inventoryRebalancer.setInventoryBalances({
419
+ [SOLANA_CHAIN]: requestedAmount,
420
+ [ARBITRUM_CHAIN]: 0n,
421
+ });
422
+ const results = await inventoryRebalancer.rebalance([route]);
423
+ expect(results).to.have.lengthOf(1);
424
+ expect(results[0].success).to.be.true;
425
+ expect(bridge.execute.called).to.be.false;
426
+ const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
427
+ expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
428
+ expect(txParams.originTokenAmount.amount).to.not.equal(requestedAmount);
429
+ });
354
430
  it('intent remains in_progress after partial fulfillment', async () => {
355
431
  const route = createTestRoute({ amount: FULL_AMOUNT });
356
432
  createTestIntent({ amount: FULL_AMOUNT });
@@ -364,6 +440,42 @@ describe('InventoryRebalancer E2E', () => {
364
440
  expect(results[0].success).to.be.true;
365
441
  });
366
442
  });
443
+ describe('Fee-Aware Probe Fallback', () => {
444
+ it('bridges when non-native destination inventory is zero', async () => {
445
+ const requestedAmount = 10000000000n;
446
+ const bufferedBridgeAmount = (requestedAmount * 105n) / 100n;
447
+ const route = createTestRoute({ amount: requestedAmount });
448
+ createTestIntent({ amount: requestedAmount });
449
+ inventoryRebalancer.setInventoryBalances({
450
+ [SOLANA_CHAIN]: 0n,
451
+ [ARBITRUM_CHAIN]: 20000000000n,
452
+ });
453
+ mockSuccessfulBridge(bufferedBridgeAmount, bufferedBridgeAmount);
454
+ const results = await inventoryRebalancer.rebalance([route]);
455
+ expect(results).to.have.lengthOf(1);
456
+ expect(results[0].success).to.be.true;
457
+ expect(warpCore.getMaxTransferAmount.called).to.be.false;
458
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
459
+ expect(bridge.execute.calledOnce).to.be.true;
460
+ const actionParams = actionTracker.createRebalanceAction.firstCall.args[0];
461
+ expect(actionParams.type).to.equal('inventory_movement');
462
+ });
463
+ it('returns failure for unrelated fee-aware probe errors', async () => {
464
+ const route = createTestRoute();
465
+ createTestIntent();
466
+ inventoryRebalancer.setInventoryBalances({
467
+ [SOLANA_CHAIN]: 1n,
468
+ [ARBITRUM_CHAIN]: 20000000000n,
469
+ });
470
+ warpCore.getMaxTransferAmount.rejects(new Error('RPC down'));
471
+ const results = await inventoryRebalancer.rebalance([route]);
472
+ expect(results).to.have.lengthOf(1);
473
+ expect(results[0].success).to.be.false;
474
+ expect(results[0].error).to.include('RPC down');
475
+ expect(bridge.execute.called).to.be.false;
476
+ expect(actionTracker.createRebalanceAction.called).to.be.false;
477
+ });
478
+ });
367
479
  describe('No Inventory Available', () => {
368
480
  it('returns failure when no inventory on destination chain and no other source available', async () => {
369
481
  const route = createTestRoute();
@@ -507,6 +619,42 @@ describe('InventoryRebalancer E2E', () => {
507
619
  // Verify: No new intent was created (existing was continued)
508
620
  expect(actionTracker.createRebalanceIntent.called).to.be.false;
509
621
  });
622
+ it('continues existing intent by bridging after recoverable fee-aware probe failure', async () => {
623
+ const existingIntent = createTestIntent({
624
+ id: 'existing-intent',
625
+ status: 'in_progress',
626
+ amount: 10000000000n,
627
+ externalBridge: ExternalBridgeType.LiFi,
628
+ });
629
+ const recoverableProbeError = new Error('Fee probe failed');
630
+ recoverableProbeError.cause = {
631
+ code: 'UNPREDICTABLE_GAS_LIMIT',
632
+ message: 'execution reverted: ERC20: transfer amount exceeds balance',
633
+ };
634
+ actionTracker.getPartiallyFulfilledInventoryIntents.resolves([
635
+ {
636
+ intent: existingIntent,
637
+ completedAmount: 0n,
638
+ remaining: 10000000000n,
639
+ hasInflightDeposit: false,
640
+ },
641
+ ]);
642
+ inventoryRebalancer.setInventoryBalances({
643
+ [SOLANA_CHAIN]: 1n,
644
+ [ARBITRUM_CHAIN]: 20000000000n,
645
+ });
646
+ warpCore.getMaxTransferAmount.rejects(recoverableProbeError);
647
+ mockSuccessfulBridge(10500000000n, 10500000000n);
648
+ const results = await inventoryRebalancer.rebalance([
649
+ createTestRoute({ amount: 5000000000n }),
650
+ ]);
651
+ expect(results).to.have.lengthOf(1);
652
+ expect(results[0].success).to.be.true;
653
+ expect(bridge.execute.calledOnce).to.be.true;
654
+ expect(actionTracker.createRebalanceIntent.called).to.be.false;
655
+ const actionParams = actionTracker.createRebalanceAction.firstCall.args[0];
656
+ expect(actionParams.type).to.equal('inventory_movement');
657
+ });
510
658
  });
511
659
  describe('Error Handling', () => {
512
660
  it('handles transaction send failure', async () => {
@@ -1190,6 +1338,56 @@ describe('InventoryRebalancer E2E', () => {
1190
1338
  // Verify: Bridge was called twice (once for each source)
1191
1339
  expect(bridge.execute.callCount).to.equal(2);
1192
1340
  });
1341
+ it('does not inflate each split bridge plan to minViableTransfer', async () => {
1342
+ const amount = 7000000000000000n;
1343
+ const perChainInventory = 6000000000000000n;
1344
+ const reservedGas = 1000000000n;
1345
+ for (const token of warpCore.tokens) {
1346
+ if (token.chainName === ARBITRUM_CHAIN ||
1347
+ token.chainName === SOLANA_CHAIN ||
1348
+ token.chainName === BASE_CHAIN) {
1349
+ token.standard = TokenStandard.EvmHypNative;
1350
+ }
1351
+ }
1352
+ const route = createTestRoute({ amount });
1353
+ createTestIntent({ amount });
1354
+ inventoryRebalancer.setInventoryBalances({
1355
+ [SOLANA_CHAIN]: 0n,
1356
+ [ARBITRUM_CHAIN]: perChainInventory,
1357
+ [BASE_CHAIN]: perChainInventory,
1358
+ });
1359
+ bridge.quote.callsFake(async (params) => createMockBridgeQuote({
1360
+ fromAmount: params.fromAmount ?? params.toAmount,
1361
+ toAmount: params.toAmount ?? params.fromAmount,
1362
+ toAmountMin: params.toAmount ?? params.fromAmount,
1363
+ requestParams: params.toAmount !== undefined
1364
+ ? { ...params, toAmount: params.toAmount }
1365
+ : { ...params, fromAmount: params.fromAmount },
1366
+ }));
1367
+ bridge.execute.resolves({
1368
+ txHash: '0xBridgeTxHash',
1369
+ fromChain: 42161,
1370
+ toChain: 1399811149,
1371
+ });
1372
+ const results = await inventoryRebalancer.rebalance([route]);
1373
+ expect(results).to.have.lengthOf(1);
1374
+ expect(results[0].success).to.be.true;
1375
+ expect(bridge.execute.callCount).to.equal(2);
1376
+ const maxPerSourceOutput = perChainInventory - reservedGas;
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;
1390
+ });
1193
1391
  it('applies 5% buffer to total bridge amount', async () => {
1194
1392
  // Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
1195
1393
  const amount = BigInt(1e18); // 1 ETH
@@ -1201,14 +1399,18 @@ describe('InventoryRebalancer E2E', () => {
1201
1399
  [SOLANA_CHAIN]: 0n,
1202
1400
  [ARBITRUM_CHAIN]: availableInventory,
1203
1401
  });
1204
- // Capture the quote amount from executeInventoryMovement
1205
- // (calculateMaxViableBridgeAmount doesn't quote for ERC20 tokens)
1206
- let quotedFromAmount;
1402
+ let quotedTargetOutput;
1207
1403
  bridge.quote.callsFake(async (params) => {
1208
- quotedFromAmount = params.fromAmount;
1404
+ if (params.toAmount !== undefined) {
1405
+ quotedTargetOutput = params.toAmount;
1406
+ }
1209
1407
  return createMockBridgeQuote({
1210
1408
  fromAmount: params.fromAmount ?? params.toAmount,
1211
- toAmount: params.fromAmount ?? params.toAmount,
1409
+ toAmount: params.toAmount ?? params.fromAmount,
1410
+ toAmountMin: params.toAmount ?? params.fromAmount,
1411
+ requestParams: params.toAmount !== undefined
1412
+ ? { ...params, toAmount: params.toAmount }
1413
+ : { ...params, fromAmount: params.fromAmount },
1212
1414
  });
1213
1415
  });
1214
1416
  bridge.execute.resolves({
@@ -1218,10 +1420,359 @@ describe('InventoryRebalancer E2E', () => {
1218
1420
  });
1219
1421
  await inventoryRebalancer.rebalance([route]);
1220
1422
  // Verify: 5% buffer applied (1 ETH * 1.05 = 1.05 ETH)
1221
- // The bridge plan uses pre-validated amounts (for ERC20, full inventory available)
1222
- // But the target is (amount * 105%), so if source has >= target, we bridge exactly target
1223
1423
  const expectedWithBuffer = (amount * 105n) / 100n;
1224
- expect(quotedFromAmount).to.equal(expectedWithBuffer);
1424
+ expect(quotedTargetOutput).to.equal(expectedWithBuffer);
1425
+ });
1426
+ it('bridges only the mixed-decimal shortfall after dust partial skip', async () => {
1427
+ const canonicalAmount = 1n;
1428
+ const destinationDust = 1000000000000n - 1n;
1429
+ const sourceInventory = 1000000000000n;
1430
+ warpCore.tokens.find((t) => t.chainName === SOLANA_CHAIN).scale = {
1431
+ numerator: 1n,
1432
+ denominator: 1000000000000n,
1433
+ };
1434
+ const route = createTestRoute({ amount: canonicalAmount });
1435
+ createTestIntent({ amount: canonicalAmount });
1436
+ inventoryRebalancer.setInventoryBalances({
1437
+ [SOLANA_CHAIN]: destinationDust,
1438
+ [ARBITRUM_CHAIN]: sourceInventory,
1439
+ });
1440
+ let reverseQuoteTargetOutput;
1441
+ bridge.quote.callsFake(async (params) => {
1442
+ if (params.toAmount !== undefined) {
1443
+ reverseQuoteTargetOutput = params.toAmount;
1444
+ }
1445
+ return createMockBridgeQuote({
1446
+ fromAmount: params.fromAmount ?? params.toAmount,
1447
+ toAmount: params.toAmount ?? params.fromAmount,
1448
+ toAmountMin: params.toAmount ?? params.fromAmount,
1449
+ requestParams: params.toAmount !== undefined
1450
+ ? { ...params, toAmount: params.toAmount }
1451
+ : { ...params, fromAmount: params.fromAmount },
1452
+ });
1453
+ });
1454
+ bridge.execute.resolves({
1455
+ txHash: '0xBridgeTxHash',
1456
+ fromChain: 42161,
1457
+ toChain: 1399811149,
1458
+ });
1459
+ const results = await inventoryRebalancer.rebalance([route]);
1460
+ expect(results).to.have.lengthOf(1);
1461
+ expect(results[0].success).to.be.true;
1462
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
1463
+ expect(bridge.execute.calledOnce).to.be.true;
1464
+ expect(reverseQuoteTargetOutput).to.equal(1n);
1465
+ });
1466
+ it('plans LiFi target output in destination-local units for mixed-decimal routes', async () => {
1467
+ const canonicalAmount = 1000000n;
1468
+ const availableInventory = BigInt(2e18);
1469
+ warpCore.tokens.find((t) => t.chainName === SOLANA_CHAIN).scale = {
1470
+ numerator: 1n,
1471
+ denominator: 1000000000000n,
1472
+ };
1473
+ const route = createTestRoute({ amount: canonicalAmount });
1474
+ createTestIntent({ amount: canonicalAmount });
1475
+ inventoryRebalancer.setInventoryBalances({
1476
+ [SOLANA_CHAIN]: 0n,
1477
+ [ARBITRUM_CHAIN]: availableInventory,
1478
+ });
1479
+ let quotedTargetOutput;
1480
+ bridge.quote.callsFake(async (params) => {
1481
+ if (params.toAmount !== undefined) {
1482
+ quotedTargetOutput = params.toAmount;
1483
+ }
1484
+ return createMockBridgeQuote({
1485
+ fromAmount: params.fromAmount ?? params.toAmount,
1486
+ toAmount: params.toAmount ?? params.fromAmount,
1487
+ toAmountMin: params.toAmount ?? params.fromAmount,
1488
+ requestParams: params.toAmount !== undefined
1489
+ ? { ...params, toAmount: params.toAmount }
1490
+ : { ...params, fromAmount: params.fromAmount },
1491
+ });
1492
+ });
1493
+ bridge.execute.resolves({
1494
+ txHash: '0xBridgeTxHash',
1495
+ fromChain: 42161,
1496
+ toChain: 1399811149,
1497
+ });
1498
+ await inventoryRebalancer.rebalance([route]);
1499
+ expect(quotedTargetOutput).to.equal(1050000000000000000n);
1500
+ });
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 () => {
1572
+ const amount = BigInt(1e18);
1573
+ const sourceInventory = BigInt(2e18);
1574
+ const targetWithBuffer = (amount * 105n) / 100n;
1575
+ const route = createTestRoute({ amount });
1576
+ createTestIntent({ amount });
1577
+ inventoryRebalancer.setInventoryBalances({
1578
+ [SOLANA_CHAIN]: 0n,
1579
+ [ARBITRUM_CHAIN]: sourceInventory,
1580
+ });
1581
+ bridge.quote
1582
+ .onFirstCall()
1583
+ .resolves(createMockBridgeQuote({
1584
+ fromAmount: sourceInventory,
1585
+ toAmount: sourceInventory,
1586
+ toAmountMin: sourceInventory,
1587
+ requestParams: {
1588
+ fromChain: 42161,
1589
+ toChain: 1399811149,
1590
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1591
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1592
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1593
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1594
+ fromAmount: sourceInventory,
1595
+ },
1596
+ }))
1597
+ .onSecondCall()
1598
+ .resolves(createMockBridgeQuote({
1599
+ fromAmount: sourceInventory + 1n,
1600
+ toAmount: targetWithBuffer,
1601
+ toAmountMin: targetWithBuffer,
1602
+ requestParams: {
1603
+ fromChain: 42161,
1604
+ toChain: 1399811149,
1605
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1606
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1607
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1608
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1609
+ toAmount: targetWithBuffer,
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
+ },
1626
+ }));
1627
+ const results = await inventoryRebalancer.rebalance([route]);
1628
+ expect(results).to.have.lengthOf(1);
1629
+ expect(results[0].success).to.be.false;
1630
+ expect(results[0].error).to.include('All inventory movements failed');
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);
1671
+ expect(bridge.execute.called).to.be.false;
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());
1225
1776
  });
1226
1777
  it('continues when some bridges fail', async () => {
1227
1778
  const amount = BigInt(1e18);
@@ -1402,11 +1953,31 @@ describe('InventoryRebalancer E2E', () => {
1402
1953
  [ARBITRUM_CHAIN]: tokenBalance,
1403
1954
  });
1404
1955
  // Quote with high gas costs (but for ERC20, this shouldn't trigger viability check)
1405
- bridge.quote.resolves(createMockBridgeQuote({
1406
- fromAmount: BigInt(1.05e18),
1407
- toAmount: BigInt(1e18),
1956
+ bridge.quote
1957
+ .onFirstCall()
1958
+ .resolves(createMockBridgeQuote({
1959
+ fromAmount: tokenBalance,
1960
+ toAmount: tokenBalance,
1961
+ toAmountMin: tokenBalance,
1408
1962
  gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
1409
1963
  feeCosts: 0n,
1964
+ }))
1965
+ .onSecondCall()
1966
+ .resolves(createMockBridgeQuote({
1967
+ fromAmount: tokenBalance,
1968
+ toAmount: tokenBalance,
1969
+ toAmountMin: tokenBalance,
1970
+ gasCosts: BigInt(1e18),
1971
+ feeCosts: 0n,
1972
+ requestParams: {
1973
+ fromChain: 42161,
1974
+ toChain: 1399811149,
1975
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1976
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1977
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1978
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1979
+ toAmount: tokenBalance,
1980
+ },
1410
1981
  }));
1411
1982
  bridge.execute.resolves({
1412
1983
  txHash: '0xERC20BridgeTxHash',
@@ -1418,6 +1989,9 @@ describe('InventoryRebalancer E2E', () => {
1418
1989
  expect(results).to.have.lengthOf(1);
1419
1990
  expect(results[0].success).to.be.true;
1420
1991
  expect(bridge.execute.calledOnce).to.be.true;
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;
1421
1995
  });
1422
1996
  it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
1423
1997
  // Setup: Native token with enough balance for viable bridge
@@ -1447,11 +2021,40 @@ describe('InventoryRebalancer E2E', () => {
1447
2021
  // maxViable = 1 ETH - 0.02 ETH = 0.98 ETH
1448
2022
  // 10% threshold = 0.1 ETH, estimatedGas (0.02) < threshold → viable
1449
2023
  const gasCosts = BigInt(0.001e18); // 0.001 ETH
1450
- bridge.quote.resolves(createMockBridgeQuote({
2024
+ const maxViable = rawBalance - gasCosts * 20n;
2025
+ bridge.quote
2026
+ .onFirstCall()
2027
+ .resolves(createMockBridgeQuote({
1451
2028
  fromAmount: rawBalance,
1452
2029
  toAmount: rawBalance - BigInt(1e15),
2030
+ toAmountMin: rawBalance - BigInt(1e15),
2031
+ gasCosts,
2032
+ feeCosts: 0n,
2033
+ }))
2034
+ .onSecondCall()
2035
+ .resolves(createMockBridgeQuote({
2036
+ fromAmount: maxViable,
2037
+ toAmount: maxViable - BigInt(1e15),
2038
+ toAmountMin: maxViable - BigInt(1e15),
1453
2039
  gasCosts,
1454
2040
  feeCosts: 0n,
2041
+ }))
2042
+ .onThirdCall()
2043
+ .resolves(createMockBridgeQuote({
2044
+ fromAmount: BigInt(0.525e18),
2045
+ toAmount: BigInt(0.525e18),
2046
+ toAmountMin: BigInt(0.525e18),
2047
+ gasCosts,
2048
+ feeCosts: 0n,
2049
+ requestParams: {
2050
+ fromChain: 42161,
2051
+ toChain: 1399811149,
2052
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2053
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
2054
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2055
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
2056
+ toAmount: BigInt(0.525e18),
2057
+ },
1455
2058
  }));
1456
2059
  bridge.execute.resolves({
1457
2060
  txHash: '0xSuccessBridgeTxHash',
@@ -1463,15 +2066,94 @@ describe('InventoryRebalancer E2E', () => {
1463
2066
  expect(results).to.have.lengthOf(1);
1464
2067
  expect(results[0].success).to.be.true;
1465
2068
  expect(bridge.execute.calledOnce).to.be.true;
1466
- // Verify: The quoted fromAmount should be the target (since maxViable > target)
1467
- // For the execution quote (second quote call):
1468
- // targetWithBuffer = (0.5 ETH) * 1.05 = 0.525 ETH (for non-inventory execution, costs are 0)
1469
- const executionQuoteCall = bridge.quote
1470
- .getCalls()
1471
- .find((call) => call.args[0].fromAmount !== undefined &&
1472
- call.args[0].fromAmount !== rawBalance);
1473
- // Since maxViable (0.98 ETH) > targetWithBuffer (0.525 ETH), we bridge exactly targetWithBuffer
1474
- expect(executionQuoteCall).to.exist;
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;
2072
+ expect(executionTargetOutput).to.be.a('bigint');
2073
+ if (executionTargetOutput === undefined) {
2074
+ throw new Error('Expected reverse quote to set toAmount');
2075
+ }
2076
+ expect(executionTargetOutput > amount).to.be.true;
2077
+ expect(executionTargetOutput <= maxViable).to.be.true;
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;
1475
2157
  });
1476
2158
  it('handles quote failures gracefully by skipping the source chain', async () => {
1477
2159
  // Setup: Native token where quote fails