@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.
- package/dist/core/InventoryRebalancer.d.ts +11 -19
- package/dist/core/InventoryRebalancer.d.ts.map +1 -1
- package/dist/core/InventoryRebalancer.js +336 -268
- package/dist/core/InventoryRebalancer.js.map +1 -1
- package/dist/core/InventoryRebalancer.test.js +397 -23
- package/dist/core/InventoryRebalancer.test.js.map +1 -1
- package/dist/core/Rebalancer.d.ts.map +1 -1
- package/dist/core/Rebalancer.js +12 -6
- package/dist/core/Rebalancer.js.map +1 -1
- package/dist/core/Rebalancer.test.js +51 -0
- package/dist/core/Rebalancer.test.js.map +1 -1
- package/dist/core/RebalancerOrchestrator.test.js +0 -1
- package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
- package/dist/core/RebalancerService.d.ts +2 -3
- package/dist/core/RebalancerService.d.ts.map +1 -1
- package/dist/core/RebalancerService.js +3 -2
- package/dist/core/RebalancerService.js.map +1 -1
- package/dist/core/RebalancerService.test.js +24 -0
- package/dist/core/RebalancerService.test.js.map +1 -1
- package/dist/e2e/harness/TestHelpers.js +1 -2
- package/dist/e2e/harness/TestHelpers.js.map +1 -1
- package/dist/factories/RebalancerContextFactory.d.ts +4 -5
- package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
- package/dist/factories/RebalancerContextFactory.js +12 -7
- package/dist/factories/RebalancerContextFactory.js.map +1 -1
- package/dist/factories/RebalancerContextFactory.test.js +99 -2
- package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
- package/dist/interfaces/IRebalancer.d.ts +4 -2
- package/dist/interfaces/IRebalancer.d.ts.map +1 -1
- package/dist/metrics/scripts/metrics.d.ts +1 -1
- package/dist/monitor/Monitor.d.ts.map +1 -1
- package/dist/monitor/Monitor.js +14 -6
- package/dist/monitor/Monitor.js.map +1 -1
- package/dist/strategy/BaseStrategy.d.ts.map +1 -1
- package/dist/strategy/BaseStrategy.js +13 -11
- package/dist/strategy/BaseStrategy.js.map +1 -1
- package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
- package/dist/strategy/CollateralDeficitStrategy.js +2 -2
- package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
- package/dist/strategy/MinAmountStrategy.d.ts +1 -0
- package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
- package/dist/strategy/MinAmountStrategy.js +12 -8
- package/dist/strategy/MinAmountStrategy.js.map +1 -1
- package/dist/strategy/MinAmountStrategy.test.js +189 -2
- package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
- package/dist/test/helpers.d.ts +11 -3
- package/dist/test/helpers.d.ts.map +1 -1
- package/dist/test/helpers.js +9 -11
- package/dist/test/helpers.js.map +1 -1
- package/dist/test/lifiMocks.d.ts.map +1 -1
- package/dist/test/lifiMocks.js +5 -2
- package/dist/test/lifiMocks.js.map +1 -1
- package/dist/tracking/ActionTracker.d.ts.map +1 -1
- package/dist/tracking/ActionTracker.js +2 -1
- package/dist/tracking/ActionTracker.js.map +1 -1
- package/dist/tracking/ActionTracker.test.js +39 -0
- package/dist/tracking/ActionTracker.test.js.map +1 -1
- package/dist/utils/balanceUtils.d.ts +7 -1
- package/dist/utils/balanceUtils.d.ts.map +1 -1
- package/dist/utils/balanceUtils.js +39 -1
- package/dist/utils/balanceUtils.js.map +1 -1
- package/dist/utils/balanceUtils.test.js +55 -1
- package/dist/utils/balanceUtils.test.js.map +1 -1
- package/dist/utils/blockTag.d.ts +3 -3
- package/dist/utils/blockTag.d.ts.map +1 -1
- package/dist/utils/blockTag.js +1 -1
- package/dist/utils/blockTag.js.map +1 -1
- package/package.json +7 -7
- package/src/core/InventoryRebalancer.test.ts +503 -38
- package/src/core/InventoryRebalancer.ts +483 -350
- package/src/core/Rebalancer.test.ts +84 -0
- package/src/core/Rebalancer.ts +22 -6
- package/src/core/RebalancerOrchestrator.test.ts +0 -1
- package/src/core/RebalancerService.test.ts +35 -0
- package/src/core/RebalancerService.ts +9 -5
- package/src/e2e/harness/TestHelpers.ts +3 -3
- package/src/factories/RebalancerContextFactory.test.ts +143 -6
- package/src/factories/RebalancerContextFactory.ts +29 -17
- package/src/interfaces/IRebalancer.ts +4 -1
- package/src/monitor/Monitor.ts +19 -6
- package/src/strategy/BaseStrategy.ts +18 -15
- package/src/strategy/CollateralDeficitStrategy.ts +4 -3
- package/src/strategy/MinAmountStrategy.test.ts +238 -2
- package/src/strategy/MinAmountStrategy.ts +29 -17
- package/src/test/helpers.ts +13 -12
- package/src/test/lifiMocks.ts +5 -2
- package/src/tracking/ActionTracker.test.ts +47 -0
- package/src/tracking/ActionTracker.ts +2 -1
- package/src/utils/balanceUtils.test.ts +87 -1
- package/src/utils/balanceUtils.ts +73 -2
- package/src/utils/blockTag.ts +9 -4
|
@@ -154,6 +154,9 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
154
154
|
}),
|
|
155
155
|
},
|
|
156
156
|
findToken: Sinon.stub().returns(null),
|
|
157
|
+
getMaxTransferAmount: Sinon.stub().callsFake(
|
|
158
|
+
async ({ balance }) => balance,
|
|
159
|
+
),
|
|
157
160
|
getTransferRemoteTxs: Sinon.stub().resolves([
|
|
158
161
|
{
|
|
159
162
|
category: 'transfer',
|
|
@@ -277,6 +280,21 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
277
280
|
return intent;
|
|
278
281
|
}
|
|
279
282
|
|
|
283
|
+
function mockSuccessfulBridge(fromAmount: bigint, toAmount: bigint): void {
|
|
284
|
+
bridge.quote.resolves(
|
|
285
|
+
createMockBridgeQuote({
|
|
286
|
+
fromAmount,
|
|
287
|
+
toAmount,
|
|
288
|
+
toAmountMin: toAmount,
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
bridge.execute.resolves({
|
|
292
|
+
txHash: '0xBridgeTxHash',
|
|
293
|
+
fromChain: 42161,
|
|
294
|
+
toChain: 1399811149,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
280
298
|
describe('Basic Inventory Rebalance (Sufficient Inventory)', () => {
|
|
281
299
|
// NOTE: Strategy route is arbitrum (surplus) → solana (deficit)
|
|
282
300
|
// But execution calls transferRemote FROM solana TO arbitrum (swapped direction)
|
|
@@ -396,11 +414,39 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
396
414
|
const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
|
|
397
415
|
expect(actionParams.txHash).to.equal('0xSolanaTxHash');
|
|
398
416
|
});
|
|
417
|
+
|
|
418
|
+
it('denormalizes inventory execution amounts but records canonical deposit amount', async () => {
|
|
419
|
+
const route = createTestRoute({ amount: 1_000_000n });
|
|
420
|
+
createTestIntent({ amount: 1_000_000n });
|
|
421
|
+
warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
|
|
422
|
+
numerator: 1n,
|
|
423
|
+
denominator: 1_000_000_000_000n,
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
inventoryRebalancer.setInventoryBalances({
|
|
427
|
+
[SOLANA_CHAIN]: 1_000_000_000_000_000_000n,
|
|
428
|
+
[ARBITRUM_CHAIN]: 0n,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
432
|
+
|
|
433
|
+
expect(results).to.have.lengthOf(1);
|
|
434
|
+
expect(results[0].success).to.be.true;
|
|
435
|
+
|
|
436
|
+
const txParams = warpCore.getTransferRemoteTxs.firstCall.args[0];
|
|
437
|
+
expect(txParams.originTokenAmount.amount).to.equal(
|
|
438
|
+
1_000_000_000_000_000_000n,
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
|
|
442
|
+
expect(actionParams.amount).to.equal(1_000_000n);
|
|
443
|
+
});
|
|
399
444
|
});
|
|
400
445
|
|
|
401
446
|
describe('Partial Fulfillment (Insufficient Inventory)', () => {
|
|
402
|
-
// Partial transfers happen when maxTransferable >= minViableTransfer
|
|
403
|
-
// For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so
|
|
447
|
+
// Partial transfers happen when maxTransferable >= minViableTransfer.
|
|
448
|
+
// For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
|
|
449
|
+
// positive fee-aware maxTransferable remains viable.
|
|
404
450
|
const PARTIAL_AMOUNT = BigInt(5e15); // 0.005 ETH - above threshold
|
|
405
451
|
const FULL_AMOUNT = BigInt(1e16); // 0.01 ETH
|
|
406
452
|
|
|
@@ -433,6 +479,65 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
433
479
|
expect(actionParams.amount).to.equal(PARTIAL_AMOUNT);
|
|
434
480
|
});
|
|
435
481
|
|
|
482
|
+
it('uses fee-aware maxTransferable for non-native token fees', async () => {
|
|
483
|
+
const requestedAmount = 19998000000n;
|
|
484
|
+
const availableInventory = 102466n;
|
|
485
|
+
const safeTransferAmount = 102400n;
|
|
486
|
+
|
|
487
|
+
warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
|
|
488
|
+
|
|
489
|
+
const route = createTestRoute({ amount: requestedAmount });
|
|
490
|
+
createTestIntent({ amount: requestedAmount });
|
|
491
|
+
|
|
492
|
+
inventoryRebalancer.setInventoryBalances({
|
|
493
|
+
[SOLANA_CHAIN]: availableInventory,
|
|
494
|
+
[ARBITRUM_CHAIN]: 0n,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
498
|
+
|
|
499
|
+
expect(results).to.have.lengthOf(1);
|
|
500
|
+
expect(results[0].success).to.be.true;
|
|
501
|
+
expect(warpCore.getMaxTransferAmount.calledOnce).to.be.true;
|
|
502
|
+
|
|
503
|
+
const maxTransferArgs = warpCore.getMaxTransferAmount.firstCall.args[0];
|
|
504
|
+
expect(maxTransferArgs.balance.amount).to.equal(availableInventory);
|
|
505
|
+
expect(maxTransferArgs.destination).to.equal(ARBITRUM_CHAIN);
|
|
506
|
+
|
|
507
|
+
const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
|
|
508
|
+
expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
|
|
509
|
+
expect(txParams).to.not.have.property('interchainFee');
|
|
510
|
+
expect(txParams).to.not.have.property('tokenFeeQuote');
|
|
511
|
+
|
|
512
|
+
const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
|
|
513
|
+
expect(actionParams.amount).to.equal(safeTransferAmount);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('downgrades full non-native transfers to partial when fee headroom is missing', async () => {
|
|
517
|
+
const requestedAmount = 102466n;
|
|
518
|
+
const safeTransferAmount = 102400n;
|
|
519
|
+
|
|
520
|
+
warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
|
|
521
|
+
|
|
522
|
+
const route = createTestRoute({ amount: requestedAmount });
|
|
523
|
+
createTestIntent({ amount: requestedAmount });
|
|
524
|
+
|
|
525
|
+
inventoryRebalancer.setInventoryBalances({
|
|
526
|
+
[SOLANA_CHAIN]: requestedAmount,
|
|
527
|
+
[ARBITRUM_CHAIN]: 0n,
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
531
|
+
|
|
532
|
+
expect(results).to.have.lengthOf(1);
|
|
533
|
+
expect(results[0].success).to.be.true;
|
|
534
|
+
expect(bridge.execute.called).to.be.false;
|
|
535
|
+
|
|
536
|
+
const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
|
|
537
|
+
expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
|
|
538
|
+
expect(txParams.originTokenAmount.amount).to.not.equal(requestedAmount);
|
|
539
|
+
});
|
|
540
|
+
|
|
436
541
|
it('intent remains in_progress after partial fulfillment', async () => {
|
|
437
542
|
const route = createTestRoute({ amount: FULL_AMOUNT });
|
|
438
543
|
createTestIntent({ amount: FULL_AMOUNT });
|
|
@@ -450,6 +555,52 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
450
555
|
});
|
|
451
556
|
});
|
|
452
557
|
|
|
558
|
+
describe('Fee-Aware Probe Fallback', () => {
|
|
559
|
+
it('bridges when non-native destination inventory is zero', async () => {
|
|
560
|
+
const requestedAmount = 10000000000n;
|
|
561
|
+
const bufferedBridgeAmount = (requestedAmount * 105n) / 100n;
|
|
562
|
+
const route = createTestRoute({ amount: requestedAmount });
|
|
563
|
+
createTestIntent({ amount: requestedAmount });
|
|
564
|
+
|
|
565
|
+
inventoryRebalancer.setInventoryBalances({
|
|
566
|
+
[SOLANA_CHAIN]: 0n,
|
|
567
|
+
[ARBITRUM_CHAIN]: 20000000000n,
|
|
568
|
+
});
|
|
569
|
+
mockSuccessfulBridge(bufferedBridgeAmount, bufferedBridgeAmount);
|
|
570
|
+
|
|
571
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
572
|
+
|
|
573
|
+
expect(results).to.have.lengthOf(1);
|
|
574
|
+
expect(results[0].success).to.be.true;
|
|
575
|
+
expect(warpCore.getMaxTransferAmount.called).to.be.false;
|
|
576
|
+
expect(warpCore.getTransferRemoteTxs.called).to.be.false;
|
|
577
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
578
|
+
|
|
579
|
+
const actionParams =
|
|
580
|
+
actionTracker.createRebalanceAction.firstCall.args[0];
|
|
581
|
+
expect(actionParams.type).to.equal('inventory_movement');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('returns failure for unrelated fee-aware probe errors', async () => {
|
|
585
|
+
const route = createTestRoute();
|
|
586
|
+
createTestIntent();
|
|
587
|
+
|
|
588
|
+
inventoryRebalancer.setInventoryBalances({
|
|
589
|
+
[SOLANA_CHAIN]: 1n,
|
|
590
|
+
[ARBITRUM_CHAIN]: 20000000000n,
|
|
591
|
+
});
|
|
592
|
+
warpCore.getMaxTransferAmount.rejects(new Error('RPC down'));
|
|
593
|
+
|
|
594
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
595
|
+
|
|
596
|
+
expect(results).to.have.lengthOf(1);
|
|
597
|
+
expect(results[0].success).to.be.false;
|
|
598
|
+
expect(results[0].error).to.include('RPC down');
|
|
599
|
+
expect(bridge.execute.called).to.be.false;
|
|
600
|
+
expect(actionTracker.createRebalanceAction.called).to.be.false;
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
453
604
|
describe('No Inventory Available', () => {
|
|
454
605
|
it('returns failure when no inventory on destination chain and no other source available', async () => {
|
|
455
606
|
const route = createTestRoute();
|
|
@@ -626,6 +777,51 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
626
777
|
// Verify: No new intent was created (existing was continued)
|
|
627
778
|
expect(actionTracker.createRebalanceIntent.called).to.be.false;
|
|
628
779
|
});
|
|
780
|
+
|
|
781
|
+
it('continues existing intent by bridging after recoverable fee-aware probe failure', async () => {
|
|
782
|
+
const existingIntent = createTestIntent({
|
|
783
|
+
id: 'existing-intent',
|
|
784
|
+
status: 'in_progress',
|
|
785
|
+
amount: 10000000000n,
|
|
786
|
+
externalBridge: ExternalBridgeType.LiFi,
|
|
787
|
+
});
|
|
788
|
+
const recoverableProbeError = new Error('Fee probe failed') as Error & {
|
|
789
|
+
cause?: unknown;
|
|
790
|
+
};
|
|
791
|
+
recoverableProbeError.cause = {
|
|
792
|
+
code: 'UNPREDICTABLE_GAS_LIMIT',
|
|
793
|
+
message: 'execution reverted: ERC20: transfer amount exceeds balance',
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
actionTracker.getPartiallyFulfilledInventoryIntents.resolves([
|
|
797
|
+
{
|
|
798
|
+
intent: existingIntent,
|
|
799
|
+
completedAmount: 0n,
|
|
800
|
+
remaining: 10000000000n,
|
|
801
|
+
hasInflightDeposit: false,
|
|
802
|
+
},
|
|
803
|
+
]);
|
|
804
|
+
|
|
805
|
+
inventoryRebalancer.setInventoryBalances({
|
|
806
|
+
[SOLANA_CHAIN]: 1n,
|
|
807
|
+
[ARBITRUM_CHAIN]: 20000000000n,
|
|
808
|
+
});
|
|
809
|
+
warpCore.getMaxTransferAmount.rejects(recoverableProbeError);
|
|
810
|
+
mockSuccessfulBridge(10500000000n, 10500000000n);
|
|
811
|
+
|
|
812
|
+
const results = await inventoryRebalancer.rebalance([
|
|
813
|
+
createTestRoute({ amount: 5000000000n }),
|
|
814
|
+
]);
|
|
815
|
+
|
|
816
|
+
expect(results).to.have.lengthOf(1);
|
|
817
|
+
expect(results[0].success).to.be.true;
|
|
818
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
819
|
+
expect(actionTracker.createRebalanceIntent.called).to.be.false;
|
|
820
|
+
|
|
821
|
+
const actionParams =
|
|
822
|
+
actionTracker.createRebalanceAction.firstCall.args[0];
|
|
823
|
+
expect(actionParams.type).to.equal('inventory_movement');
|
|
824
|
+
});
|
|
629
825
|
});
|
|
630
826
|
|
|
631
827
|
describe('Error Handling', () => {
|
|
@@ -1457,6 +1653,73 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1457
1653
|
expect(bridge.execute.callCount).to.equal(2);
|
|
1458
1654
|
});
|
|
1459
1655
|
|
|
1656
|
+
it('does not inflate each split bridge plan to minViableTransfer', async () => {
|
|
1657
|
+
const amount = 7_000_000_000_000_000n;
|
|
1658
|
+
const perChainInventory = 6_000_000_000_000_000n;
|
|
1659
|
+
const reservedGas = 1_000_000_000n;
|
|
1660
|
+
|
|
1661
|
+
for (const token of warpCore.tokens) {
|
|
1662
|
+
if (
|
|
1663
|
+
token.chainName === ARBITRUM_CHAIN ||
|
|
1664
|
+
token.chainName === SOLANA_CHAIN ||
|
|
1665
|
+
token.chainName === BASE_CHAIN
|
|
1666
|
+
) {
|
|
1667
|
+
token.standard = TokenStandard.EvmHypNative;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
const route = createTestRoute({ amount });
|
|
1672
|
+
createTestIntent({ amount });
|
|
1673
|
+
|
|
1674
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1675
|
+
[SOLANA_CHAIN]: 0n,
|
|
1676
|
+
[ARBITRUM_CHAIN]: perChainInventory,
|
|
1677
|
+
[BASE_CHAIN]: perChainInventory,
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
bridge.quote.callsFake(async (params: any) =>
|
|
1681
|
+
createMockBridgeQuote({
|
|
1682
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1683
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1684
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1685
|
+
requestParams:
|
|
1686
|
+
params.toAmount !== undefined
|
|
1687
|
+
? { ...params, toAmount: params.toAmount }
|
|
1688
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1689
|
+
}),
|
|
1690
|
+
);
|
|
1691
|
+
bridge.execute.resolves({
|
|
1692
|
+
txHash: '0xBridgeTxHash',
|
|
1693
|
+
fromChain: 42161,
|
|
1694
|
+
toChain: 1399811149,
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1698
|
+
|
|
1699
|
+
expect(results).to.have.lengthOf(1);
|
|
1700
|
+
expect(results[0].success).to.be.true;
|
|
1701
|
+
expect(bridge.execute.callCount).to.equal(2);
|
|
1702
|
+
|
|
1703
|
+
const reverseQuoteRequests = bridge.quote
|
|
1704
|
+
.getCalls()
|
|
1705
|
+
.map((call) => call.args[0])
|
|
1706
|
+
.filter((params) => params.toAmount !== undefined);
|
|
1707
|
+
expect(reverseQuoteRequests).to.have.lengthOf(2);
|
|
1708
|
+
|
|
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;
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1460
1723
|
it('applies 5% buffer to total bridge amount', async () => {
|
|
1461
1724
|
// Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
|
|
1462
1725
|
const amount = BigInt(1e18); // 1 ETH
|
|
@@ -1471,14 +1734,19 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1471
1734
|
[ARBITRUM_CHAIN]: availableInventory,
|
|
1472
1735
|
});
|
|
1473
1736
|
|
|
1474
|
-
|
|
1475
|
-
// (calculateMaxViableBridgeAmount doesn't quote for ERC20 tokens)
|
|
1476
|
-
let quotedFromAmount: bigint | undefined;
|
|
1737
|
+
let quotedTargetOutput: bigint | undefined;
|
|
1477
1738
|
bridge.quote.callsFake(async (params: any) => {
|
|
1478
|
-
|
|
1739
|
+
if (params.toAmount !== undefined) {
|
|
1740
|
+
quotedTargetOutput = params.toAmount;
|
|
1741
|
+
}
|
|
1479
1742
|
return createMockBridgeQuote({
|
|
1480
1743
|
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1481
|
-
toAmount: params.
|
|
1744
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1745
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1746
|
+
requestParams:
|
|
1747
|
+
params.toAmount !== undefined
|
|
1748
|
+
? { ...params, toAmount: params.toAmount }
|
|
1749
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1482
1750
|
});
|
|
1483
1751
|
});
|
|
1484
1752
|
bridge.execute.resolves({
|
|
@@ -1490,10 +1758,155 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1490
1758
|
await inventoryRebalancer.rebalance([route]);
|
|
1491
1759
|
|
|
1492
1760
|
// Verify: 5% buffer applied (1 ETH * 1.05 = 1.05 ETH)
|
|
1493
|
-
// The bridge plan uses pre-validated amounts (for ERC20, full inventory available)
|
|
1494
|
-
// But the target is (amount * 105%), so if source has >= target, we bridge exactly target
|
|
1495
1761
|
const expectedWithBuffer = (amount * 105n) / 100n;
|
|
1496
|
-
expect(
|
|
1762
|
+
expect(quotedTargetOutput).to.equal(expectedWithBuffer);
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
it('bridges only the mixed-decimal shortfall after dust partial skip', async () => {
|
|
1766
|
+
const canonicalAmount = 1n;
|
|
1767
|
+
const destinationDust = 1_000_000_000_000n - 1n;
|
|
1768
|
+
const sourceInventory = 1_000_000_000_000n;
|
|
1769
|
+
warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
|
|
1770
|
+
numerator: 1n,
|
|
1771
|
+
denominator: 1_000_000_000_000n,
|
|
1772
|
+
};
|
|
1773
|
+
|
|
1774
|
+
const route = createTestRoute({ amount: canonicalAmount });
|
|
1775
|
+
createTestIntent({ amount: canonicalAmount });
|
|
1776
|
+
|
|
1777
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1778
|
+
[SOLANA_CHAIN]: destinationDust,
|
|
1779
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
let reverseQuoteTargetOutput: bigint | undefined;
|
|
1783
|
+
bridge.quote.callsFake(async (params: any) => {
|
|
1784
|
+
if (params.toAmount !== undefined) {
|
|
1785
|
+
reverseQuoteTargetOutput = params.toAmount;
|
|
1786
|
+
}
|
|
1787
|
+
return createMockBridgeQuote({
|
|
1788
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1789
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1790
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1791
|
+
requestParams:
|
|
1792
|
+
params.toAmount !== undefined
|
|
1793
|
+
? { ...params, toAmount: params.toAmount }
|
|
1794
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1795
|
+
});
|
|
1796
|
+
});
|
|
1797
|
+
bridge.execute.resolves({
|
|
1798
|
+
txHash: '0xBridgeTxHash',
|
|
1799
|
+
fromChain: 42161,
|
|
1800
|
+
toChain: 1399811149,
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1804
|
+
|
|
1805
|
+
expect(results).to.have.lengthOf(1);
|
|
1806
|
+
expect(results[0].success).to.be.true;
|
|
1807
|
+
expect(warpCore.getTransferRemoteTxs.called).to.be.false;
|
|
1808
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
1809
|
+
expect(reverseQuoteTargetOutput).to.equal(1n);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
it('plans LiFi target output in destination-local units for mixed-decimal routes', async () => {
|
|
1813
|
+
const canonicalAmount = 1_000_000n;
|
|
1814
|
+
const availableInventory = BigInt(2e18);
|
|
1815
|
+
warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
|
|
1816
|
+
numerator: 1n,
|
|
1817
|
+
denominator: 1_000_000_000_000n,
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1820
|
+
const route = createTestRoute({ amount: canonicalAmount });
|
|
1821
|
+
createTestIntent({ amount: canonicalAmount });
|
|
1822
|
+
|
|
1823
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1824
|
+
[SOLANA_CHAIN]: 0n,
|
|
1825
|
+
[ARBITRUM_CHAIN]: availableInventory,
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
let quotedTargetOutput: bigint | undefined;
|
|
1829
|
+
bridge.quote.callsFake(async (params: any) => {
|
|
1830
|
+
if (params.toAmount !== undefined) {
|
|
1831
|
+
quotedTargetOutput = params.toAmount;
|
|
1832
|
+
}
|
|
1833
|
+
return createMockBridgeQuote({
|
|
1834
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1835
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1836
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1837
|
+
requestParams:
|
|
1838
|
+
params.toAmount !== undefined
|
|
1839
|
+
? { ...params, toAmount: params.toAmount }
|
|
1840
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1841
|
+
});
|
|
1842
|
+
});
|
|
1843
|
+
bridge.execute.resolves({
|
|
1844
|
+
txHash: '0xBridgeTxHash',
|
|
1845
|
+
fromChain: 42161,
|
|
1846
|
+
toChain: 1399811149,
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
await inventoryRebalancer.rebalance([route]);
|
|
1850
|
+
|
|
1851
|
+
expect(quotedTargetOutput).to.equal(1_050_000_000_000_000_000n);
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
it('fails bridge execution when reverse quote exceeds planned source capacity', async () => {
|
|
1855
|
+
const amount = BigInt(1e18);
|
|
1856
|
+
const sourceInventory = BigInt(2e18);
|
|
1857
|
+
const targetWithBuffer = (amount * 105n) / 100n;
|
|
1858
|
+
|
|
1859
|
+
const route = createTestRoute({ amount });
|
|
1860
|
+
createTestIntent({ amount });
|
|
1861
|
+
|
|
1862
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1863
|
+
[SOLANA_CHAIN]: 0n,
|
|
1864
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
bridge.quote
|
|
1868
|
+
.onFirstCall()
|
|
1869
|
+
.resolves(
|
|
1870
|
+
createMockBridgeQuote({
|
|
1871
|
+
fromAmount: sourceInventory,
|
|
1872
|
+
toAmount: sourceInventory,
|
|
1873
|
+
toAmountMin: sourceInventory,
|
|
1874
|
+
requestParams: {
|
|
1875
|
+
fromChain: 42161,
|
|
1876
|
+
toChain: 1399811149,
|
|
1877
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1878
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1879
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1880
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1881
|
+
fromAmount: sourceInventory,
|
|
1882
|
+
},
|
|
1883
|
+
}),
|
|
1884
|
+
)
|
|
1885
|
+
.onSecondCall()
|
|
1886
|
+
.resolves(
|
|
1887
|
+
createMockBridgeQuote({
|
|
1888
|
+
fromAmount: sourceInventory + 1n,
|
|
1889
|
+
toAmount: targetWithBuffer,
|
|
1890
|
+
toAmountMin: targetWithBuffer,
|
|
1891
|
+
requestParams: {
|
|
1892
|
+
fromChain: 42161,
|
|
1893
|
+
toChain: 1399811149,
|
|
1894
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1895
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1896
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1897
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1898
|
+
toAmount: targetWithBuffer,
|
|
1899
|
+
},
|
|
1900
|
+
}),
|
|
1901
|
+
);
|
|
1902
|
+
|
|
1903
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1904
|
+
|
|
1905
|
+
expect(results).to.have.lengthOf(1);
|
|
1906
|
+
expect(results[0].success).to.be.false;
|
|
1907
|
+
expect(results[0].error).to.include('All inventory movements failed');
|
|
1908
|
+
expect(results[0].error).to.include('exceeded planned source capacity');
|
|
1909
|
+
expect(bridge.execute.called).to.be.false;
|
|
1497
1910
|
});
|
|
1498
1911
|
|
|
1499
1912
|
it('continues when some bridges fail', async () => {
|
|
@@ -1721,14 +2134,36 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1721
2134
|
});
|
|
1722
2135
|
|
|
1723
2136
|
// Quote with high gas costs (but for ERC20, this shouldn't trigger viability check)
|
|
1724
|
-
bridge.quote
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
2137
|
+
bridge.quote
|
|
2138
|
+
.onFirstCall()
|
|
2139
|
+
.resolves(
|
|
2140
|
+
createMockBridgeQuote({
|
|
2141
|
+
fromAmount: tokenBalance,
|
|
2142
|
+
toAmount: tokenBalance,
|
|
2143
|
+
toAmountMin: tokenBalance,
|
|
2144
|
+
gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
|
|
2145
|
+
feeCosts: 0n,
|
|
2146
|
+
}),
|
|
2147
|
+
)
|
|
2148
|
+
.onSecondCall()
|
|
2149
|
+
.resolves(
|
|
2150
|
+
createMockBridgeQuote({
|
|
2151
|
+
fromAmount: tokenBalance,
|
|
2152
|
+
toAmount: tokenBalance,
|
|
2153
|
+
toAmountMin: tokenBalance,
|
|
2154
|
+
gasCosts: BigInt(1e18),
|
|
2155
|
+
feeCosts: 0n,
|
|
2156
|
+
requestParams: {
|
|
2157
|
+
fromChain: 42161,
|
|
2158
|
+
toChain: 1399811149,
|
|
2159
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2160
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2161
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2162
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2163
|
+
toAmount: tokenBalance,
|
|
2164
|
+
},
|
|
2165
|
+
}),
|
|
2166
|
+
);
|
|
1732
2167
|
|
|
1733
2168
|
bridge.execute.resolves({
|
|
1734
2169
|
txHash: '0xERC20BridgeTxHash',
|
|
@@ -1742,6 +2177,7 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1742
2177
|
expect(results).to.have.lengthOf(1);
|
|
1743
2178
|
expect(results[0].success).to.be.true;
|
|
1744
2179
|
expect(bridge.execute.calledOnce).to.be.true;
|
|
2180
|
+
expect(bridge.quote.getCall(1)?.args[0].toAmount).to.equal(tokenBalance);
|
|
1745
2181
|
});
|
|
1746
2182
|
|
|
1747
2183
|
it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
|
|
@@ -1777,14 +2213,47 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1777
2213
|
// maxViable = 1 ETH - 0.02 ETH = 0.98 ETH
|
|
1778
2214
|
// 10% threshold = 0.1 ETH, estimatedGas (0.02) < threshold → viable
|
|
1779
2215
|
const gasCosts = BigInt(0.001e18); // 0.001 ETH
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
2216
|
+
const maxViable = rawBalance - gasCosts * 20n;
|
|
2217
|
+
bridge.quote
|
|
2218
|
+
.onFirstCall()
|
|
2219
|
+
.resolves(
|
|
2220
|
+
createMockBridgeQuote({
|
|
2221
|
+
fromAmount: rawBalance,
|
|
2222
|
+
toAmount: rawBalance - BigInt(1e15),
|
|
2223
|
+
toAmountMin: rawBalance - BigInt(1e15),
|
|
2224
|
+
gasCosts,
|
|
2225
|
+
feeCosts: 0n,
|
|
2226
|
+
}),
|
|
2227
|
+
)
|
|
2228
|
+
.onSecondCall()
|
|
2229
|
+
.resolves(
|
|
2230
|
+
createMockBridgeQuote({
|
|
2231
|
+
fromAmount: maxViable,
|
|
2232
|
+
toAmount: maxViable - BigInt(1e15),
|
|
2233
|
+
toAmountMin: maxViable - BigInt(1e15),
|
|
2234
|
+
gasCosts,
|
|
2235
|
+
feeCosts: 0n,
|
|
2236
|
+
}),
|
|
2237
|
+
)
|
|
2238
|
+
.onThirdCall()
|
|
2239
|
+
.resolves(
|
|
2240
|
+
createMockBridgeQuote({
|
|
2241
|
+
fromAmount: BigInt(0.525e18),
|
|
2242
|
+
toAmount: BigInt(0.525e18),
|
|
2243
|
+
toAmountMin: BigInt(0.525e18),
|
|
2244
|
+
gasCosts,
|
|
2245
|
+
feeCosts: 0n,
|
|
2246
|
+
requestParams: {
|
|
2247
|
+
fromChain: 42161,
|
|
2248
|
+
toChain: 1399811149,
|
|
2249
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2250
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2251
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2252
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2253
|
+
toAmount: BigInt(0.525e18),
|
|
2254
|
+
},
|
|
2255
|
+
}),
|
|
2256
|
+
);
|
|
1788
2257
|
|
|
1789
2258
|
bridge.execute.resolves({
|
|
1790
2259
|
txHash: '0xSuccessBridgeTxHash',
|
|
@@ -1799,18 +2268,14 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1799
2268
|
expect(results[0].success).to.be.true;
|
|
1800
2269
|
expect(bridge.execute.calledOnce).to.be.true;
|
|
1801
2270
|
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
call.args[0].fromAmount !== rawBalance,
|
|
1811
|
-
);
|
|
1812
|
-
// Since maxViable (0.98 ETH) > targetWithBuffer (0.525 ETH), we bridge exactly targetWithBuffer
|
|
1813
|
-
expect(executionQuoteCall).to.exist;
|
|
2271
|
+
expect(bridge.quote.getCall(1)?.args[0].fromAmount).to.equal(maxViable);
|
|
2272
|
+
const executionTargetOutput = bridge.quote.getCall(2)?.args[0].toAmount;
|
|
2273
|
+
expect(executionTargetOutput).to.be.a('bigint');
|
|
2274
|
+
if (executionTargetOutput === undefined) {
|
|
2275
|
+
throw new Error('Expected reverse quote to set toAmount');
|
|
2276
|
+
}
|
|
2277
|
+
expect(executionTargetOutput > amount).to.be.true;
|
|
2278
|
+
expect(executionTargetOutput <= maxViable).to.be.true;
|
|
1814
2279
|
});
|
|
1815
2280
|
|
|
1816
2281
|
it('handles quote failures gracefully by skipping the source chain', async () => {
|