@hyperlane-xyz/rebalancer 27.2.12 → 27.2.13

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 (91) hide show
  1. package/dist/core/InventoryRebalancer.d.ts +11 -19
  2. package/dist/core/InventoryRebalancer.d.ts.map +1 -1
  3. package/dist/core/InventoryRebalancer.js +336 -268
  4. package/dist/core/InventoryRebalancer.js.map +1 -1
  5. package/dist/core/InventoryRebalancer.test.js +397 -23
  6. package/dist/core/InventoryRebalancer.test.js.map +1 -1
  7. package/dist/core/Rebalancer.d.ts.map +1 -1
  8. package/dist/core/Rebalancer.js +12 -6
  9. package/dist/core/Rebalancer.js.map +1 -1
  10. package/dist/core/Rebalancer.test.js +51 -0
  11. package/dist/core/Rebalancer.test.js.map +1 -1
  12. package/dist/core/RebalancerOrchestrator.test.js +0 -1
  13. package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
  14. package/dist/core/RebalancerService.d.ts +2 -3
  15. package/dist/core/RebalancerService.d.ts.map +1 -1
  16. package/dist/core/RebalancerService.js +3 -2
  17. package/dist/core/RebalancerService.js.map +1 -1
  18. package/dist/core/RebalancerService.test.js +24 -0
  19. package/dist/core/RebalancerService.test.js.map +1 -1
  20. package/dist/e2e/harness/TestHelpers.js +1 -2
  21. package/dist/e2e/harness/TestHelpers.js.map +1 -1
  22. package/dist/factories/RebalancerContextFactory.d.ts +4 -5
  23. package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
  24. package/dist/factories/RebalancerContextFactory.js +12 -7
  25. package/dist/factories/RebalancerContextFactory.js.map +1 -1
  26. package/dist/factories/RebalancerContextFactory.test.js +99 -2
  27. package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
  28. package/dist/interfaces/IRebalancer.d.ts +4 -2
  29. package/dist/interfaces/IRebalancer.d.ts.map +1 -1
  30. package/dist/metrics/scripts/metrics.d.ts +1 -1
  31. package/dist/monitor/Monitor.d.ts.map +1 -1
  32. package/dist/monitor/Monitor.js +14 -6
  33. package/dist/monitor/Monitor.js.map +1 -1
  34. package/dist/strategy/BaseStrategy.d.ts.map +1 -1
  35. package/dist/strategy/BaseStrategy.js +13 -11
  36. package/dist/strategy/BaseStrategy.js.map +1 -1
  37. package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
  38. package/dist/strategy/CollateralDeficitStrategy.js +2 -2
  39. package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
  40. package/dist/strategy/MinAmountStrategy.d.ts +1 -0
  41. package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
  42. package/dist/strategy/MinAmountStrategy.js +12 -8
  43. package/dist/strategy/MinAmountStrategy.js.map +1 -1
  44. package/dist/strategy/MinAmountStrategy.test.js +189 -2
  45. package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
  46. package/dist/test/helpers.d.ts +11 -3
  47. package/dist/test/helpers.d.ts.map +1 -1
  48. package/dist/test/helpers.js +9 -11
  49. package/dist/test/helpers.js.map +1 -1
  50. package/dist/test/lifiMocks.d.ts.map +1 -1
  51. package/dist/test/lifiMocks.js +5 -2
  52. package/dist/test/lifiMocks.js.map +1 -1
  53. package/dist/tracking/ActionTracker.d.ts.map +1 -1
  54. package/dist/tracking/ActionTracker.js +2 -1
  55. package/dist/tracking/ActionTracker.js.map +1 -1
  56. package/dist/tracking/ActionTracker.test.js +39 -0
  57. package/dist/tracking/ActionTracker.test.js.map +1 -1
  58. package/dist/utils/balanceUtils.d.ts +7 -1
  59. package/dist/utils/balanceUtils.d.ts.map +1 -1
  60. package/dist/utils/balanceUtils.js +39 -1
  61. package/dist/utils/balanceUtils.js.map +1 -1
  62. package/dist/utils/balanceUtils.test.js +55 -1
  63. package/dist/utils/balanceUtils.test.js.map +1 -1
  64. package/dist/utils/blockTag.d.ts +3 -3
  65. package/dist/utils/blockTag.d.ts.map +1 -1
  66. package/dist/utils/blockTag.js +1 -1
  67. package/dist/utils/blockTag.js.map +1 -1
  68. package/package.json +7 -7
  69. package/src/core/InventoryRebalancer.test.ts +503 -38
  70. package/src/core/InventoryRebalancer.ts +483 -350
  71. package/src/core/Rebalancer.test.ts +84 -0
  72. package/src/core/Rebalancer.ts +22 -6
  73. package/src/core/RebalancerOrchestrator.test.ts +0 -1
  74. package/src/core/RebalancerService.test.ts +35 -0
  75. package/src/core/RebalancerService.ts +9 -5
  76. package/src/e2e/harness/TestHelpers.ts +3 -3
  77. package/src/factories/RebalancerContextFactory.test.ts +143 -6
  78. package/src/factories/RebalancerContextFactory.ts +29 -17
  79. package/src/interfaces/IRebalancer.ts +4 -1
  80. package/src/monitor/Monitor.ts +19 -6
  81. package/src/strategy/BaseStrategy.ts +18 -15
  82. package/src/strategy/CollateralDeficitStrategy.ts +4 -3
  83. package/src/strategy/MinAmountStrategy.test.ts +238 -2
  84. package/src/strategy/MinAmountStrategy.ts +29 -17
  85. package/src/test/helpers.ts +13 -12
  86. package/src/test/lifiMocks.ts +5 -2
  87. package/src/tracking/ActionTracker.test.ts +47 -0
  88. package/src/tracking/ActionTracker.ts +2 -1
  89. package/src/utils/balanceUtils.test.ts +87 -1
  90. package/src/utils/balanceUtils.ts +73 -2
  91. package/src/utils/blockTag.ts +9 -4
@@ -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,58 @@ 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 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
+ 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;
1392
+ });
1193
1393
  it('applies 5% buffer to total bridge amount', async () => {
1194
1394
  // Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
1195
1395
  const amount = BigInt(1e18); // 1 ETH
@@ -1201,14 +1401,18 @@ describe('InventoryRebalancer E2E', () => {
1201
1401
  [SOLANA_CHAIN]: 0n,
1202
1402
  [ARBITRUM_CHAIN]: availableInventory,
1203
1403
  });
1204
- // Capture the quote amount from executeInventoryMovement
1205
- // (calculateMaxViableBridgeAmount doesn't quote for ERC20 tokens)
1206
- let quotedFromAmount;
1404
+ let quotedTargetOutput;
1207
1405
  bridge.quote.callsFake(async (params) => {
1208
- quotedFromAmount = params.fromAmount;
1406
+ if (params.toAmount !== undefined) {
1407
+ quotedTargetOutput = params.toAmount;
1408
+ }
1209
1409
  return createMockBridgeQuote({
1210
1410
  fromAmount: params.fromAmount ?? params.toAmount,
1211
- toAmount: params.fromAmount ?? params.toAmount,
1411
+ toAmount: params.toAmount ?? params.fromAmount,
1412
+ toAmountMin: params.toAmount ?? params.fromAmount,
1413
+ requestParams: params.toAmount !== undefined
1414
+ ? { ...params, toAmount: params.toAmount }
1415
+ : { ...params, fromAmount: params.fromAmount },
1212
1416
  });
1213
1417
  });
1214
1418
  bridge.execute.resolves({
@@ -1218,10 +1422,131 @@ describe('InventoryRebalancer E2E', () => {
1218
1422
  });
1219
1423
  await inventoryRebalancer.rebalance([route]);
1220
1424
  // 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
1425
  const expectedWithBuffer = (amount * 105n) / 100n;
1224
- expect(quotedFromAmount).to.equal(expectedWithBuffer);
1426
+ expect(quotedTargetOutput).to.equal(expectedWithBuffer);
1427
+ });
1428
+ it('bridges only the mixed-decimal shortfall after dust partial skip', async () => {
1429
+ const canonicalAmount = 1n;
1430
+ const destinationDust = 1000000000000n - 1n;
1431
+ const sourceInventory = 1000000000000n;
1432
+ warpCore.tokens.find((t) => t.chainName === SOLANA_CHAIN).scale = {
1433
+ numerator: 1n,
1434
+ denominator: 1000000000000n,
1435
+ };
1436
+ const route = createTestRoute({ amount: canonicalAmount });
1437
+ createTestIntent({ amount: canonicalAmount });
1438
+ inventoryRebalancer.setInventoryBalances({
1439
+ [SOLANA_CHAIN]: destinationDust,
1440
+ [ARBITRUM_CHAIN]: sourceInventory,
1441
+ });
1442
+ let reverseQuoteTargetOutput;
1443
+ bridge.quote.callsFake(async (params) => {
1444
+ if (params.toAmount !== undefined) {
1445
+ reverseQuoteTargetOutput = params.toAmount;
1446
+ }
1447
+ return createMockBridgeQuote({
1448
+ fromAmount: params.fromAmount ?? params.toAmount,
1449
+ toAmount: params.toAmount ?? params.fromAmount,
1450
+ toAmountMin: params.toAmount ?? params.fromAmount,
1451
+ requestParams: params.toAmount !== undefined
1452
+ ? { ...params, toAmount: params.toAmount }
1453
+ : { ...params, fromAmount: params.fromAmount },
1454
+ });
1455
+ });
1456
+ bridge.execute.resolves({
1457
+ txHash: '0xBridgeTxHash',
1458
+ fromChain: 42161,
1459
+ toChain: 1399811149,
1460
+ });
1461
+ const results = await inventoryRebalancer.rebalance([route]);
1462
+ expect(results).to.have.lengthOf(1);
1463
+ expect(results[0].success).to.be.true;
1464
+ expect(warpCore.getTransferRemoteTxs.called).to.be.false;
1465
+ expect(bridge.execute.calledOnce).to.be.true;
1466
+ expect(reverseQuoteTargetOutput).to.equal(1n);
1467
+ });
1468
+ it('plans LiFi target output in destination-local units for mixed-decimal routes', async () => {
1469
+ const canonicalAmount = 1000000n;
1470
+ const availableInventory = BigInt(2e18);
1471
+ warpCore.tokens.find((t) => t.chainName === SOLANA_CHAIN).scale = {
1472
+ numerator: 1n,
1473
+ denominator: 1000000000000n,
1474
+ };
1475
+ const route = createTestRoute({ amount: canonicalAmount });
1476
+ createTestIntent({ amount: canonicalAmount });
1477
+ inventoryRebalancer.setInventoryBalances({
1478
+ [SOLANA_CHAIN]: 0n,
1479
+ [ARBITRUM_CHAIN]: availableInventory,
1480
+ });
1481
+ let quotedTargetOutput;
1482
+ bridge.quote.callsFake(async (params) => {
1483
+ if (params.toAmount !== undefined) {
1484
+ quotedTargetOutput = params.toAmount;
1485
+ }
1486
+ return createMockBridgeQuote({
1487
+ fromAmount: params.fromAmount ?? params.toAmount,
1488
+ toAmount: params.toAmount ?? params.fromAmount,
1489
+ toAmountMin: params.toAmount ?? params.fromAmount,
1490
+ requestParams: params.toAmount !== undefined
1491
+ ? { ...params, toAmount: params.toAmount }
1492
+ : { ...params, fromAmount: params.fromAmount },
1493
+ });
1494
+ });
1495
+ bridge.execute.resolves({
1496
+ txHash: '0xBridgeTxHash',
1497
+ fromChain: 42161,
1498
+ toChain: 1399811149,
1499
+ });
1500
+ await inventoryRebalancer.rebalance([route]);
1501
+ expect(quotedTargetOutput).to.equal(1050000000000000000n);
1502
+ });
1503
+ it('fails bridge execution when reverse quote exceeds planned source capacity', async () => {
1504
+ const amount = BigInt(1e18);
1505
+ const sourceInventory = BigInt(2e18);
1506
+ const targetWithBuffer = (amount * 105n) / 100n;
1507
+ const route = createTestRoute({ amount });
1508
+ createTestIntent({ amount });
1509
+ inventoryRebalancer.setInventoryBalances({
1510
+ [SOLANA_CHAIN]: 0n,
1511
+ [ARBITRUM_CHAIN]: sourceInventory,
1512
+ });
1513
+ bridge.quote
1514
+ .onFirstCall()
1515
+ .resolves(createMockBridgeQuote({
1516
+ fromAmount: sourceInventory,
1517
+ toAmount: sourceInventory,
1518
+ toAmountMin: sourceInventory,
1519
+ requestParams: {
1520
+ fromChain: 42161,
1521
+ toChain: 1399811149,
1522
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1523
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1524
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1525
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1526
+ fromAmount: sourceInventory,
1527
+ },
1528
+ }))
1529
+ .onSecondCall()
1530
+ .resolves(createMockBridgeQuote({
1531
+ fromAmount: sourceInventory + 1n,
1532
+ toAmount: targetWithBuffer,
1533
+ toAmountMin: targetWithBuffer,
1534
+ requestParams: {
1535
+ fromChain: 42161,
1536
+ toChain: 1399811149,
1537
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1538
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1539
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1540
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1541
+ toAmount: targetWithBuffer,
1542
+ },
1543
+ }));
1544
+ const results = await inventoryRebalancer.rebalance([route]);
1545
+ expect(results).to.have.lengthOf(1);
1546
+ expect(results[0].success).to.be.false;
1547
+ expect(results[0].error).to.include('All inventory movements failed');
1548
+ expect(results[0].error).to.include('exceeded planned source capacity');
1549
+ expect(bridge.execute.called).to.be.false;
1225
1550
  });
1226
1551
  it('continues when some bridges fail', async () => {
1227
1552
  const amount = BigInt(1e18);
@@ -1402,11 +1727,31 @@ describe('InventoryRebalancer E2E', () => {
1402
1727
  [ARBITRUM_CHAIN]: tokenBalance,
1403
1728
  });
1404
1729
  // 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),
1730
+ bridge.quote
1731
+ .onFirstCall()
1732
+ .resolves(createMockBridgeQuote({
1733
+ fromAmount: tokenBalance,
1734
+ toAmount: tokenBalance,
1735
+ toAmountMin: tokenBalance,
1408
1736
  gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
1409
1737
  feeCosts: 0n,
1738
+ }))
1739
+ .onSecondCall()
1740
+ .resolves(createMockBridgeQuote({
1741
+ fromAmount: tokenBalance,
1742
+ toAmount: tokenBalance,
1743
+ toAmountMin: tokenBalance,
1744
+ gasCosts: BigInt(1e18),
1745
+ feeCosts: 0n,
1746
+ requestParams: {
1747
+ fromChain: 42161,
1748
+ toChain: 1399811149,
1749
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1750
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1751
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1752
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1753
+ toAmount: tokenBalance,
1754
+ },
1410
1755
  }));
1411
1756
  bridge.execute.resolves({
1412
1757
  txHash: '0xERC20BridgeTxHash',
@@ -1418,6 +1763,7 @@ describe('InventoryRebalancer E2E', () => {
1418
1763
  expect(results).to.have.lengthOf(1);
1419
1764
  expect(results[0].success).to.be.true;
1420
1765
  expect(bridge.execute.calledOnce).to.be.true;
1766
+ expect(bridge.quote.getCall(1)?.args[0].toAmount).to.equal(tokenBalance);
1421
1767
  });
1422
1768
  it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
1423
1769
  // Setup: Native token with enough balance for viable bridge
@@ -1447,11 +1793,40 @@ describe('InventoryRebalancer E2E', () => {
1447
1793
  // maxViable = 1 ETH - 0.02 ETH = 0.98 ETH
1448
1794
  // 10% threshold = 0.1 ETH, estimatedGas (0.02) < threshold → viable
1449
1795
  const gasCosts = BigInt(0.001e18); // 0.001 ETH
1450
- bridge.quote.resolves(createMockBridgeQuote({
1796
+ const maxViable = rawBalance - gasCosts * 20n;
1797
+ bridge.quote
1798
+ .onFirstCall()
1799
+ .resolves(createMockBridgeQuote({
1451
1800
  fromAmount: rawBalance,
1452
1801
  toAmount: rawBalance - BigInt(1e15),
1802
+ toAmountMin: rawBalance - BigInt(1e15),
1803
+ gasCosts,
1804
+ feeCosts: 0n,
1805
+ }))
1806
+ .onSecondCall()
1807
+ .resolves(createMockBridgeQuote({
1808
+ fromAmount: maxViable,
1809
+ toAmount: maxViable - BigInt(1e15),
1810
+ toAmountMin: maxViable - BigInt(1e15),
1453
1811
  gasCosts,
1454
1812
  feeCosts: 0n,
1813
+ }))
1814
+ .onThirdCall()
1815
+ .resolves(createMockBridgeQuote({
1816
+ fromAmount: BigInt(0.525e18),
1817
+ toAmount: BigInt(0.525e18),
1818
+ toAmountMin: BigInt(0.525e18),
1819
+ gasCosts,
1820
+ feeCosts: 0n,
1821
+ requestParams: {
1822
+ fromChain: 42161,
1823
+ toChain: 1399811149,
1824
+ fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1825
+ toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
1826
+ fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1827
+ toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1828
+ toAmount: BigInt(0.525e18),
1829
+ },
1455
1830
  }));
1456
1831
  bridge.execute.resolves({
1457
1832
  txHash: '0xSuccessBridgeTxHash',
@@ -1463,15 +1838,14 @@ describe('InventoryRebalancer E2E', () => {
1463
1838
  expect(results).to.have.lengthOf(1);
1464
1839
  expect(results[0].success).to.be.true;
1465
1840
  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;
1841
+ expect(bridge.quote.getCall(1)?.args[0].fromAmount).to.equal(maxViable);
1842
+ const executionTargetOutput = bridge.quote.getCall(2)?.args[0].toAmount;
1843
+ expect(executionTargetOutput).to.be.a('bigint');
1844
+ if (executionTargetOutput === undefined) {
1845
+ throw new Error('Expected reverse quote to set toAmount');
1846
+ }
1847
+ expect(executionTargetOutput > amount).to.be.true;
1848
+ expect(executionTargetOutput <= maxViable).to.be.true;
1475
1849
  });
1476
1850
  it('handles quote failures gracefully by skipping the source chain', async () => {
1477
1851
  // Setup: Native token where quote fails