@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.
- package/dist/bridges/LiFiBridge.js +1 -1
- package/dist/bridges/LiFiBridge.js.map +1 -1
- package/dist/bridges/LiFiBridge.test.js +37 -0
- package/dist/bridges/LiFiBridge.test.js.map +1 -1
- package/dist/core/InventoryRebalancer.d.ts +13 -19
- package/dist/core/InventoryRebalancer.d.ts.map +1 -1
- package/dist/core/InventoryRebalancer.js +400 -274
- package/dist/core/InventoryRebalancer.js.map +1 -1
- package/dist/core/InventoryRebalancer.test.js +706 -24
- 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/bridges/LiFiBridge.test.ts +43 -0
- package/src/bridges/LiFiBridge.ts +1 -1
- package/src/core/InventoryRebalancer.test.ts +932 -38
- package/src/core/InventoryRebalancer.ts +579 -361
- 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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chai, { expect } from 'chai';
|
|
2
2
|
import chaiAsPromised from 'chai-as-promised';
|
|
3
3
|
import { Wallet } from 'ethers';
|
|
4
|
+
import type { Logger } from 'pino';
|
|
4
5
|
import { pino } from 'pino';
|
|
5
6
|
import Sinon, { type SinonStubbedInstance } from 'sinon';
|
|
6
7
|
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
WarpTxCategory,
|
|
13
14
|
type WarpCore,
|
|
14
15
|
} from '@hyperlane-xyz/sdk';
|
|
15
|
-
import { ProtocolType } from '@hyperlane-xyz/utils';
|
|
16
|
+
import { assert, ProtocolType } from '@hyperlane-xyz/utils';
|
|
16
17
|
|
|
17
18
|
import { ExternalBridgeType } from '../config/types.js';
|
|
18
19
|
import type { IExternalBridge } from '../interfaces/IExternalBridge.js';
|
|
@@ -154,6 +155,9 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
154
155
|
}),
|
|
155
156
|
},
|
|
156
157
|
findToken: Sinon.stub().returns(null),
|
|
158
|
+
getMaxTransferAmount: Sinon.stub().callsFake(
|
|
159
|
+
async ({ balance }) => balance,
|
|
160
|
+
),
|
|
157
161
|
getTransferRemoteTxs: Sinon.stub().resolves([
|
|
158
162
|
{
|
|
159
163
|
category: 'transfer',
|
|
@@ -277,6 +281,21 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
277
281
|
return intent;
|
|
278
282
|
}
|
|
279
283
|
|
|
284
|
+
function mockSuccessfulBridge(fromAmount: bigint, toAmount: bigint): void {
|
|
285
|
+
bridge.quote.resolves(
|
|
286
|
+
createMockBridgeQuote({
|
|
287
|
+
fromAmount,
|
|
288
|
+
toAmount,
|
|
289
|
+
toAmountMin: toAmount,
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
bridge.execute.resolves({
|
|
293
|
+
txHash: '0xBridgeTxHash',
|
|
294
|
+
fromChain: 42161,
|
|
295
|
+
toChain: 1399811149,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
280
299
|
describe('Basic Inventory Rebalance (Sufficient Inventory)', () => {
|
|
281
300
|
// NOTE: Strategy route is arbitrum (surplus) → solana (deficit)
|
|
282
301
|
// But execution calls transferRemote FROM solana TO arbitrum (swapped direction)
|
|
@@ -396,11 +415,39 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
396
415
|
const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
|
|
397
416
|
expect(actionParams.txHash).to.equal('0xSolanaTxHash');
|
|
398
417
|
});
|
|
418
|
+
|
|
419
|
+
it('denormalizes inventory execution amounts but records canonical deposit amount', async () => {
|
|
420
|
+
const route = createTestRoute({ amount: 1_000_000n });
|
|
421
|
+
createTestIntent({ amount: 1_000_000n });
|
|
422
|
+
warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
|
|
423
|
+
numerator: 1n,
|
|
424
|
+
denominator: 1_000_000_000_000n,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
inventoryRebalancer.setInventoryBalances({
|
|
428
|
+
[SOLANA_CHAIN]: 1_000_000_000_000_000_000n,
|
|
429
|
+
[ARBITRUM_CHAIN]: 0n,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
433
|
+
|
|
434
|
+
expect(results).to.have.lengthOf(1);
|
|
435
|
+
expect(results[0].success).to.be.true;
|
|
436
|
+
|
|
437
|
+
const txParams = warpCore.getTransferRemoteTxs.firstCall.args[0];
|
|
438
|
+
expect(txParams.originTokenAmount.amount).to.equal(
|
|
439
|
+
1_000_000_000_000_000_000n,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
|
|
443
|
+
expect(actionParams.amount).to.equal(1_000_000n);
|
|
444
|
+
});
|
|
399
445
|
});
|
|
400
446
|
|
|
401
447
|
describe('Partial Fulfillment (Insufficient Inventory)', () => {
|
|
402
|
-
// Partial transfers happen when maxTransferable >= minViableTransfer
|
|
403
|
-
// For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so
|
|
448
|
+
// Partial transfers happen when maxTransferable >= minViableTransfer.
|
|
449
|
+
// For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
|
|
450
|
+
// positive fee-aware maxTransferable remains viable.
|
|
404
451
|
const PARTIAL_AMOUNT = BigInt(5e15); // 0.005 ETH - above threshold
|
|
405
452
|
const FULL_AMOUNT = BigInt(1e16); // 0.01 ETH
|
|
406
453
|
|
|
@@ -433,6 +480,65 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
433
480
|
expect(actionParams.amount).to.equal(PARTIAL_AMOUNT);
|
|
434
481
|
});
|
|
435
482
|
|
|
483
|
+
it('uses fee-aware maxTransferable for non-native token fees', async () => {
|
|
484
|
+
const requestedAmount = 19998000000n;
|
|
485
|
+
const availableInventory = 102466n;
|
|
486
|
+
const safeTransferAmount = 102400n;
|
|
487
|
+
|
|
488
|
+
warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
|
|
489
|
+
|
|
490
|
+
const route = createTestRoute({ amount: requestedAmount });
|
|
491
|
+
createTestIntent({ amount: requestedAmount });
|
|
492
|
+
|
|
493
|
+
inventoryRebalancer.setInventoryBalances({
|
|
494
|
+
[SOLANA_CHAIN]: availableInventory,
|
|
495
|
+
[ARBITRUM_CHAIN]: 0n,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
499
|
+
|
|
500
|
+
expect(results).to.have.lengthOf(1);
|
|
501
|
+
expect(results[0].success).to.be.true;
|
|
502
|
+
expect(warpCore.getMaxTransferAmount.calledOnce).to.be.true;
|
|
503
|
+
|
|
504
|
+
const maxTransferArgs = warpCore.getMaxTransferAmount.firstCall.args[0];
|
|
505
|
+
expect(maxTransferArgs.balance.amount).to.equal(availableInventory);
|
|
506
|
+
expect(maxTransferArgs.destination).to.equal(ARBITRUM_CHAIN);
|
|
507
|
+
|
|
508
|
+
const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
|
|
509
|
+
expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
|
|
510
|
+
expect(txParams).to.not.have.property('interchainFee');
|
|
511
|
+
expect(txParams).to.not.have.property('tokenFeeQuote');
|
|
512
|
+
|
|
513
|
+
const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
|
|
514
|
+
expect(actionParams.amount).to.equal(safeTransferAmount);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('downgrades full non-native transfers to partial when fee headroom is missing', async () => {
|
|
518
|
+
const requestedAmount = 102466n;
|
|
519
|
+
const safeTransferAmount = 102400n;
|
|
520
|
+
|
|
521
|
+
warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
|
|
522
|
+
|
|
523
|
+
const route = createTestRoute({ amount: requestedAmount });
|
|
524
|
+
createTestIntent({ amount: requestedAmount });
|
|
525
|
+
|
|
526
|
+
inventoryRebalancer.setInventoryBalances({
|
|
527
|
+
[SOLANA_CHAIN]: requestedAmount,
|
|
528
|
+
[ARBITRUM_CHAIN]: 0n,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
532
|
+
|
|
533
|
+
expect(results).to.have.lengthOf(1);
|
|
534
|
+
expect(results[0].success).to.be.true;
|
|
535
|
+
expect(bridge.execute.called).to.be.false;
|
|
536
|
+
|
|
537
|
+
const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
|
|
538
|
+
expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
|
|
539
|
+
expect(txParams.originTokenAmount.amount).to.not.equal(requestedAmount);
|
|
540
|
+
});
|
|
541
|
+
|
|
436
542
|
it('intent remains in_progress after partial fulfillment', async () => {
|
|
437
543
|
const route = createTestRoute({ amount: FULL_AMOUNT });
|
|
438
544
|
createTestIntent({ amount: FULL_AMOUNT });
|
|
@@ -450,6 +556,52 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
450
556
|
});
|
|
451
557
|
});
|
|
452
558
|
|
|
559
|
+
describe('Fee-Aware Probe Fallback', () => {
|
|
560
|
+
it('bridges when non-native destination inventory is zero', async () => {
|
|
561
|
+
const requestedAmount = 10000000000n;
|
|
562
|
+
const bufferedBridgeAmount = (requestedAmount * 105n) / 100n;
|
|
563
|
+
const route = createTestRoute({ amount: requestedAmount });
|
|
564
|
+
createTestIntent({ amount: requestedAmount });
|
|
565
|
+
|
|
566
|
+
inventoryRebalancer.setInventoryBalances({
|
|
567
|
+
[SOLANA_CHAIN]: 0n,
|
|
568
|
+
[ARBITRUM_CHAIN]: 20000000000n,
|
|
569
|
+
});
|
|
570
|
+
mockSuccessfulBridge(bufferedBridgeAmount, bufferedBridgeAmount);
|
|
571
|
+
|
|
572
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
573
|
+
|
|
574
|
+
expect(results).to.have.lengthOf(1);
|
|
575
|
+
expect(results[0].success).to.be.true;
|
|
576
|
+
expect(warpCore.getMaxTransferAmount.called).to.be.false;
|
|
577
|
+
expect(warpCore.getTransferRemoteTxs.called).to.be.false;
|
|
578
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
579
|
+
|
|
580
|
+
const actionParams =
|
|
581
|
+
actionTracker.createRebalanceAction.firstCall.args[0];
|
|
582
|
+
expect(actionParams.type).to.equal('inventory_movement');
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('returns failure for unrelated fee-aware probe errors', async () => {
|
|
586
|
+
const route = createTestRoute();
|
|
587
|
+
createTestIntent();
|
|
588
|
+
|
|
589
|
+
inventoryRebalancer.setInventoryBalances({
|
|
590
|
+
[SOLANA_CHAIN]: 1n,
|
|
591
|
+
[ARBITRUM_CHAIN]: 20000000000n,
|
|
592
|
+
});
|
|
593
|
+
warpCore.getMaxTransferAmount.rejects(new Error('RPC down'));
|
|
594
|
+
|
|
595
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
596
|
+
|
|
597
|
+
expect(results).to.have.lengthOf(1);
|
|
598
|
+
expect(results[0].success).to.be.false;
|
|
599
|
+
expect(results[0].error).to.include('RPC down');
|
|
600
|
+
expect(bridge.execute.called).to.be.false;
|
|
601
|
+
expect(actionTracker.createRebalanceAction.called).to.be.false;
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
453
605
|
describe('No Inventory Available', () => {
|
|
454
606
|
it('returns failure when no inventory on destination chain and no other source available', async () => {
|
|
455
607
|
const route = createTestRoute();
|
|
@@ -626,6 +778,51 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
626
778
|
// Verify: No new intent was created (existing was continued)
|
|
627
779
|
expect(actionTracker.createRebalanceIntent.called).to.be.false;
|
|
628
780
|
});
|
|
781
|
+
|
|
782
|
+
it('continues existing intent by bridging after recoverable fee-aware probe failure', async () => {
|
|
783
|
+
const existingIntent = createTestIntent({
|
|
784
|
+
id: 'existing-intent',
|
|
785
|
+
status: 'in_progress',
|
|
786
|
+
amount: 10000000000n,
|
|
787
|
+
externalBridge: ExternalBridgeType.LiFi,
|
|
788
|
+
});
|
|
789
|
+
const recoverableProbeError = new Error('Fee probe failed') as Error & {
|
|
790
|
+
cause?: unknown;
|
|
791
|
+
};
|
|
792
|
+
recoverableProbeError.cause = {
|
|
793
|
+
code: 'UNPREDICTABLE_GAS_LIMIT',
|
|
794
|
+
message: 'execution reverted: ERC20: transfer amount exceeds balance',
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
actionTracker.getPartiallyFulfilledInventoryIntents.resolves([
|
|
798
|
+
{
|
|
799
|
+
intent: existingIntent,
|
|
800
|
+
completedAmount: 0n,
|
|
801
|
+
remaining: 10000000000n,
|
|
802
|
+
hasInflightDeposit: false,
|
|
803
|
+
},
|
|
804
|
+
]);
|
|
805
|
+
|
|
806
|
+
inventoryRebalancer.setInventoryBalances({
|
|
807
|
+
[SOLANA_CHAIN]: 1n,
|
|
808
|
+
[ARBITRUM_CHAIN]: 20000000000n,
|
|
809
|
+
});
|
|
810
|
+
warpCore.getMaxTransferAmount.rejects(recoverableProbeError);
|
|
811
|
+
mockSuccessfulBridge(10500000000n, 10500000000n);
|
|
812
|
+
|
|
813
|
+
const results = await inventoryRebalancer.rebalance([
|
|
814
|
+
createTestRoute({ amount: 5000000000n }),
|
|
815
|
+
]);
|
|
816
|
+
|
|
817
|
+
expect(results).to.have.lengthOf(1);
|
|
818
|
+
expect(results[0].success).to.be.true;
|
|
819
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
820
|
+
expect(actionTracker.createRebalanceIntent.called).to.be.false;
|
|
821
|
+
|
|
822
|
+
const actionParams =
|
|
823
|
+
actionTracker.createRebalanceAction.firstCall.args[0];
|
|
824
|
+
expect(actionParams.type).to.equal('inventory_movement');
|
|
825
|
+
});
|
|
629
826
|
});
|
|
630
827
|
|
|
631
828
|
describe('Error Handling', () => {
|
|
@@ -1457,6 +1654,83 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1457
1654
|
expect(bridge.execute.callCount).to.equal(2);
|
|
1458
1655
|
});
|
|
1459
1656
|
|
|
1657
|
+
it('does not inflate each split bridge plan to minViableTransfer', async () => {
|
|
1658
|
+
const amount = 7_000_000_000_000_000n;
|
|
1659
|
+
const perChainInventory = 6_000_000_000_000_000n;
|
|
1660
|
+
const reservedGas = 1_000_000_000n;
|
|
1661
|
+
|
|
1662
|
+
for (const token of warpCore.tokens) {
|
|
1663
|
+
if (
|
|
1664
|
+
token.chainName === ARBITRUM_CHAIN ||
|
|
1665
|
+
token.chainName === SOLANA_CHAIN ||
|
|
1666
|
+
token.chainName === BASE_CHAIN
|
|
1667
|
+
) {
|
|
1668
|
+
token.standard = TokenStandard.EvmHypNative;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const route = createTestRoute({ amount });
|
|
1673
|
+
createTestIntent({ amount });
|
|
1674
|
+
|
|
1675
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1676
|
+
[SOLANA_CHAIN]: 0n,
|
|
1677
|
+
[ARBITRUM_CHAIN]: perChainInventory,
|
|
1678
|
+
[BASE_CHAIN]: perChainInventory,
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
bridge.quote.callsFake(async (params: any) =>
|
|
1682
|
+
createMockBridgeQuote({
|
|
1683
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1684
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1685
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1686
|
+
requestParams:
|
|
1687
|
+
params.toAmount !== undefined
|
|
1688
|
+
? { ...params, toAmount: params.toAmount }
|
|
1689
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1690
|
+
}),
|
|
1691
|
+
);
|
|
1692
|
+
bridge.execute.resolves({
|
|
1693
|
+
txHash: '0xBridgeTxHash',
|
|
1694
|
+
fromChain: 42161,
|
|
1695
|
+
toChain: 1399811149,
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1699
|
+
|
|
1700
|
+
expect(results).to.have.lengthOf(1);
|
|
1701
|
+
expect(results[0].success).to.be.true;
|
|
1702
|
+
expect(bridge.execute.callCount).to.equal(2);
|
|
1703
|
+
|
|
1704
|
+
const maxPerSourceOutput = perChainInventory - reservedGas;
|
|
1705
|
+
const executionQuoteRequests = bridge.quote
|
|
1706
|
+
.getCalls()
|
|
1707
|
+
.slice(-2)
|
|
1708
|
+
.map((call) => call.args[0]);
|
|
1709
|
+
expect(executionQuoteRequests).to.have.lengthOf(2);
|
|
1710
|
+
|
|
1711
|
+
const forwardQuoteRequests = executionQuoteRequests.filter(
|
|
1712
|
+
(params) => params.fromAmount !== undefined,
|
|
1713
|
+
);
|
|
1714
|
+
const reverseQuoteRequests = executionQuoteRequests.filter(
|
|
1715
|
+
(params) => params.toAmount !== undefined,
|
|
1716
|
+
);
|
|
1717
|
+
const forwardAmounts = forwardQuoteRequests.map(
|
|
1718
|
+
(params) => params.fromAmount as bigint,
|
|
1719
|
+
);
|
|
1720
|
+
|
|
1721
|
+
expect(
|
|
1722
|
+
forwardQuoteRequests.length + reverseQuoteRequests.length,
|
|
1723
|
+
).to.equal(2);
|
|
1724
|
+
expect(forwardQuoteRequests.length).to.be.greaterThan(0);
|
|
1725
|
+
expect(forwardAmounts.every((amount) => amount <= maxPerSourceOutput)).to
|
|
1726
|
+
.be.true;
|
|
1727
|
+
expect(
|
|
1728
|
+
reverseQuoteRequests.every(
|
|
1729
|
+
(params) => (params.toAmount as bigint) <= maxPerSourceOutput,
|
|
1730
|
+
),
|
|
1731
|
+
).to.be.true;
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1460
1734
|
it('applies 5% buffer to total bridge amount', async () => {
|
|
1461
1735
|
// Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
|
|
1462
1736
|
const amount = BigInt(1e18); // 1 ETH
|
|
@@ -1471,14 +1745,19 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1471
1745
|
[ARBITRUM_CHAIN]: availableInventory,
|
|
1472
1746
|
});
|
|
1473
1747
|
|
|
1474
|
-
|
|
1475
|
-
// (calculateMaxViableBridgeAmount doesn't quote for ERC20 tokens)
|
|
1476
|
-
let quotedFromAmount: bigint | undefined;
|
|
1748
|
+
let quotedTargetOutput: bigint | undefined;
|
|
1477
1749
|
bridge.quote.callsFake(async (params: any) => {
|
|
1478
|
-
|
|
1750
|
+
if (params.toAmount !== undefined) {
|
|
1751
|
+
quotedTargetOutput = params.toAmount;
|
|
1752
|
+
}
|
|
1479
1753
|
return createMockBridgeQuote({
|
|
1480
1754
|
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1481
|
-
toAmount: params.
|
|
1755
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1756
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1757
|
+
requestParams:
|
|
1758
|
+
params.toAmount !== undefined
|
|
1759
|
+
? { ...params, toAmount: params.toAmount }
|
|
1760
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1482
1761
|
});
|
|
1483
1762
|
});
|
|
1484
1763
|
bridge.execute.resolves({
|
|
@@ -1490,10 +1769,465 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1490
1769
|
await inventoryRebalancer.rebalance([route]);
|
|
1491
1770
|
|
|
1492
1771
|
// 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
1772
|
const expectedWithBuffer = (amount * 105n) / 100n;
|
|
1496
|
-
expect(
|
|
1773
|
+
expect(quotedTargetOutput).to.equal(expectedWithBuffer);
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
it('bridges only the mixed-decimal shortfall after dust partial skip', async () => {
|
|
1777
|
+
const canonicalAmount = 1n;
|
|
1778
|
+
const destinationDust = 1_000_000_000_000n - 1n;
|
|
1779
|
+
const sourceInventory = 1_000_000_000_000n;
|
|
1780
|
+
warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
|
|
1781
|
+
numerator: 1n,
|
|
1782
|
+
denominator: 1_000_000_000_000n,
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
const route = createTestRoute({ amount: canonicalAmount });
|
|
1786
|
+
createTestIntent({ amount: canonicalAmount });
|
|
1787
|
+
|
|
1788
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1789
|
+
[SOLANA_CHAIN]: destinationDust,
|
|
1790
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
let reverseQuoteTargetOutput: bigint | undefined;
|
|
1794
|
+
bridge.quote.callsFake(async (params: any) => {
|
|
1795
|
+
if (params.toAmount !== undefined) {
|
|
1796
|
+
reverseQuoteTargetOutput = params.toAmount;
|
|
1797
|
+
}
|
|
1798
|
+
return createMockBridgeQuote({
|
|
1799
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1800
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1801
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1802
|
+
requestParams:
|
|
1803
|
+
params.toAmount !== undefined
|
|
1804
|
+
? { ...params, toAmount: params.toAmount }
|
|
1805
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1806
|
+
});
|
|
1807
|
+
});
|
|
1808
|
+
bridge.execute.resolves({
|
|
1809
|
+
txHash: '0xBridgeTxHash',
|
|
1810
|
+
fromChain: 42161,
|
|
1811
|
+
toChain: 1399811149,
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1815
|
+
|
|
1816
|
+
expect(results).to.have.lengthOf(1);
|
|
1817
|
+
expect(results[0].success).to.be.true;
|
|
1818
|
+
expect(warpCore.getTransferRemoteTxs.called).to.be.false;
|
|
1819
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
1820
|
+
expect(reverseQuoteTargetOutput).to.equal(1n);
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
it('plans LiFi target output in destination-local units for mixed-decimal routes', async () => {
|
|
1824
|
+
const canonicalAmount = 1_000_000n;
|
|
1825
|
+
const availableInventory = BigInt(2e18);
|
|
1826
|
+
warpCore.tokens.find((t: any) => t.chainName === SOLANA_CHAIN)!.scale = {
|
|
1827
|
+
numerator: 1n,
|
|
1828
|
+
denominator: 1_000_000_000_000n,
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
const route = createTestRoute({ amount: canonicalAmount });
|
|
1832
|
+
createTestIntent({ amount: canonicalAmount });
|
|
1833
|
+
|
|
1834
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1835
|
+
[SOLANA_CHAIN]: 0n,
|
|
1836
|
+
[ARBITRUM_CHAIN]: availableInventory,
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
let quotedTargetOutput: bigint | undefined;
|
|
1840
|
+
bridge.quote.callsFake(async (params: any) => {
|
|
1841
|
+
if (params.toAmount !== undefined) {
|
|
1842
|
+
quotedTargetOutput = params.toAmount;
|
|
1843
|
+
}
|
|
1844
|
+
return createMockBridgeQuote({
|
|
1845
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1846
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1847
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1848
|
+
requestParams:
|
|
1849
|
+
params.toAmount !== undefined
|
|
1850
|
+
? { ...params, toAmount: params.toAmount }
|
|
1851
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1852
|
+
});
|
|
1853
|
+
});
|
|
1854
|
+
bridge.execute.resolves({
|
|
1855
|
+
txHash: '0xBridgeTxHash',
|
|
1856
|
+
fromChain: 42161,
|
|
1857
|
+
toChain: 1399811149,
|
|
1858
|
+
});
|
|
1859
|
+
|
|
1860
|
+
await inventoryRebalancer.rebalance([route]);
|
|
1861
|
+
|
|
1862
|
+
expect(quotedTargetOutput).to.equal(1_050_000_000_000_000_000n);
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
it('retries forward execution when reverse quote exceeds planned source capacity', async () => {
|
|
1866
|
+
const amount = BigInt(1e18);
|
|
1867
|
+
const sourceInventory = BigInt(2e18);
|
|
1868
|
+
const targetWithBuffer = (amount * 105n) / 100n;
|
|
1869
|
+
|
|
1870
|
+
const route = createTestRoute({ amount });
|
|
1871
|
+
createTestIntent({ amount });
|
|
1872
|
+
|
|
1873
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1874
|
+
[SOLANA_CHAIN]: 0n,
|
|
1875
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
bridge.quote
|
|
1879
|
+
.onFirstCall()
|
|
1880
|
+
.resolves(
|
|
1881
|
+
createMockBridgeQuote({
|
|
1882
|
+
fromAmount: sourceInventory,
|
|
1883
|
+
toAmount: sourceInventory,
|
|
1884
|
+
toAmountMin: sourceInventory,
|
|
1885
|
+
requestParams: {
|
|
1886
|
+
fromChain: 42161,
|
|
1887
|
+
toChain: 1399811149,
|
|
1888
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1889
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1890
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1891
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1892
|
+
fromAmount: sourceInventory,
|
|
1893
|
+
},
|
|
1894
|
+
}),
|
|
1895
|
+
)
|
|
1896
|
+
.onSecondCall()
|
|
1897
|
+
.resolves(
|
|
1898
|
+
createMockBridgeQuote({
|
|
1899
|
+
fromAmount: sourceInventory + 1n,
|
|
1900
|
+
toAmount: targetWithBuffer,
|
|
1901
|
+
toAmountMin: targetWithBuffer,
|
|
1902
|
+
requestParams: {
|
|
1903
|
+
fromChain: 42161,
|
|
1904
|
+
toChain: 1399811149,
|
|
1905
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1906
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1907
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1908
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1909
|
+
toAmount: targetWithBuffer,
|
|
1910
|
+
},
|
|
1911
|
+
}),
|
|
1912
|
+
)
|
|
1913
|
+
.onThirdCall()
|
|
1914
|
+
.resolves(
|
|
1915
|
+
createMockBridgeQuote({
|
|
1916
|
+
fromAmount: sourceInventory,
|
|
1917
|
+
toAmount: targetWithBuffer - 1n,
|
|
1918
|
+
toAmountMin: targetWithBuffer - 1n,
|
|
1919
|
+
requestParams: {
|
|
1920
|
+
fromChain: 42161,
|
|
1921
|
+
toChain: 1399811149,
|
|
1922
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1923
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1924
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1925
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1926
|
+
fromAmount: sourceInventory,
|
|
1927
|
+
},
|
|
1928
|
+
}),
|
|
1929
|
+
);
|
|
1930
|
+
|
|
1931
|
+
bridge.execute.resolves({
|
|
1932
|
+
txHash: '0xBridgeTxHash',
|
|
1933
|
+
fromChain: 42161,
|
|
1934
|
+
toChain: 1399811149,
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1938
|
+
|
|
1939
|
+
expect(results).to.have.lengthOf(1);
|
|
1940
|
+
expect(results[0].success).to.be.true;
|
|
1941
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
1942
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
1943
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
|
|
1944
|
+
targetWithBuffer,
|
|
1945
|
+
);
|
|
1946
|
+
expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(
|
|
1947
|
+
sourceInventory,
|
|
1948
|
+
);
|
|
1949
|
+
expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
it('fails when forward retry still exceeds planned source capacity', async () => {
|
|
1953
|
+
const amount = BigInt(1e18);
|
|
1954
|
+
const sourceInventory = BigInt(2e18);
|
|
1955
|
+
const targetWithBuffer = (amount * 105n) / 100n;
|
|
1956
|
+
|
|
1957
|
+
const route = createTestRoute({ amount });
|
|
1958
|
+
createTestIntent({ amount });
|
|
1959
|
+
|
|
1960
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1961
|
+
[SOLANA_CHAIN]: 0n,
|
|
1962
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
bridge.quote
|
|
1966
|
+
.onFirstCall()
|
|
1967
|
+
.resolves(
|
|
1968
|
+
createMockBridgeQuote({
|
|
1969
|
+
fromAmount: sourceInventory,
|
|
1970
|
+
toAmount: sourceInventory,
|
|
1971
|
+
toAmountMin: sourceInventory,
|
|
1972
|
+
requestParams: {
|
|
1973
|
+
fromChain: 42161,
|
|
1974
|
+
toChain: 1399811149,
|
|
1975
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1976
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1977
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1978
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1979
|
+
fromAmount: sourceInventory,
|
|
1980
|
+
},
|
|
1981
|
+
}),
|
|
1982
|
+
)
|
|
1983
|
+
.onSecondCall()
|
|
1984
|
+
.resolves(
|
|
1985
|
+
createMockBridgeQuote({
|
|
1986
|
+
fromAmount: sourceInventory + 1n,
|
|
1987
|
+
toAmount: targetWithBuffer,
|
|
1988
|
+
toAmountMin: targetWithBuffer,
|
|
1989
|
+
requestParams: {
|
|
1990
|
+
fromChain: 42161,
|
|
1991
|
+
toChain: 1399811149,
|
|
1992
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1993
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1994
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1995
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1996
|
+
toAmount: targetWithBuffer,
|
|
1997
|
+
},
|
|
1998
|
+
}),
|
|
1999
|
+
)
|
|
2000
|
+
.onThirdCall()
|
|
2001
|
+
.resolves(
|
|
2002
|
+
createMockBridgeQuote({
|
|
2003
|
+
fromAmount: sourceInventory + 1n,
|
|
2004
|
+
toAmount: targetWithBuffer - 1n,
|
|
2005
|
+
toAmountMin: targetWithBuffer - 1n,
|
|
2006
|
+
requestParams: {
|
|
2007
|
+
fromChain: 42161,
|
|
2008
|
+
toChain: 1399811149,
|
|
2009
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2010
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2011
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2012
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2013
|
+
fromAmount: sourceInventory,
|
|
2014
|
+
},
|
|
2015
|
+
}),
|
|
2016
|
+
);
|
|
2017
|
+
|
|
2018
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
2019
|
+
|
|
2020
|
+
expect(results).to.have.lengthOf(1);
|
|
2021
|
+
expect(results[0].success).to.be.false;
|
|
2022
|
+
expect(results[0].error).to.include('All inventory movements failed');
|
|
2023
|
+
expect(results[0].error).to.include('exceeded planned source capacity');
|
|
2024
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
2025
|
+
expect(bridge.execute.called).to.be.false;
|
|
2026
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
|
|
2027
|
+
targetWithBuffer,
|
|
2028
|
+
);
|
|
2029
|
+
expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(
|
|
2030
|
+
sourceInventory,
|
|
2031
|
+
);
|
|
2032
|
+
expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
it('fails gracefully when execute-time bridge quote rejects', async () => {
|
|
2036
|
+
const amount = BigInt(1e18);
|
|
2037
|
+
const sourceInventory = BigInt(2e18);
|
|
2038
|
+
|
|
2039
|
+
const route = createTestRoute({ amount });
|
|
2040
|
+
createTestIntent({ amount });
|
|
2041
|
+
|
|
2042
|
+
inventoryRebalancer.setInventoryBalances({
|
|
2043
|
+
[SOLANA_CHAIN]: 0n,
|
|
2044
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
bridge.quote
|
|
2048
|
+
.onFirstCall()
|
|
2049
|
+
.resolves(
|
|
2050
|
+
createMockBridgeQuote({
|
|
2051
|
+
fromAmount: sourceInventory,
|
|
2052
|
+
toAmount: sourceInventory,
|
|
2053
|
+
toAmountMin: sourceInventory,
|
|
2054
|
+
requestParams: {
|
|
2055
|
+
fromChain: 42161,
|
|
2056
|
+
toChain: 1399811149,
|
|
2057
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2058
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2059
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2060
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2061
|
+
fromAmount: sourceInventory,
|
|
2062
|
+
},
|
|
2063
|
+
}),
|
|
2064
|
+
)
|
|
2065
|
+
.onSecondCall()
|
|
2066
|
+
.rejects(new Error('LiFi API timeout'));
|
|
2067
|
+
|
|
2068
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
2069
|
+
|
|
2070
|
+
expect(results).to.have.lengthOf(1);
|
|
2071
|
+
expect(results[0].success).to.be.false;
|
|
2072
|
+
expect(results[0].error).to.include('All inventory movements failed');
|
|
2073
|
+
expect(results[0].error).to.include('LiFi API timeout');
|
|
2074
|
+
expect(bridge.quote.callCount).to.equal(2);
|
|
2075
|
+
expect(bridge.execute.called).to.be.false;
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
it('preserves non-Error rejection reasons from parallel bridge execution', async () => {
|
|
2079
|
+
const amount = BigInt(1e18);
|
|
2080
|
+
const sourceInventory = BigInt(2e18);
|
|
2081
|
+
|
|
2082
|
+
const route = createTestRoute({ amount });
|
|
2083
|
+
createTestIntent({ amount });
|
|
2084
|
+
|
|
2085
|
+
inventoryRebalancer.setInventoryBalances({
|
|
2086
|
+
[SOLANA_CHAIN]: 0n,
|
|
2087
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
2088
|
+
});
|
|
2089
|
+
|
|
2090
|
+
bridge.quote.onFirstCall().resolves(
|
|
2091
|
+
createMockBridgeQuote({
|
|
2092
|
+
fromAmount: sourceInventory,
|
|
2093
|
+
toAmount: sourceInventory,
|
|
2094
|
+
toAmountMin: sourceInventory,
|
|
2095
|
+
requestParams: {
|
|
2096
|
+
fromChain: 42161,
|
|
2097
|
+
toChain: 1399811149,
|
|
2098
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2099
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2100
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2101
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2102
|
+
fromAmount: sourceInventory,
|
|
2103
|
+
},
|
|
2104
|
+
}),
|
|
2105
|
+
);
|
|
2106
|
+
|
|
2107
|
+
// Simulate a foreign promise rejecting with a non-Error reason at runtime.
|
|
2108
|
+
const rejectionReason = 'bridge exploded' as unknown as Error;
|
|
2109
|
+
|
|
2110
|
+
const executeInventoryMovementStub = Sinon.stub(
|
|
2111
|
+
inventoryRebalancer as unknown as {
|
|
2112
|
+
executeInventoryMovement: () => Promise<unknown>;
|
|
2113
|
+
},
|
|
2114
|
+
'executeInventoryMovement',
|
|
2115
|
+
).callsFake(() =>
|
|
2116
|
+
Promise.resolve().then(() => {
|
|
2117
|
+
throw rejectionReason;
|
|
2118
|
+
}),
|
|
2119
|
+
);
|
|
2120
|
+
|
|
2121
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
2122
|
+
|
|
2123
|
+
expect(results).to.have.lengthOf(1);
|
|
2124
|
+
expect(results[0].success).to.be.false;
|
|
2125
|
+
expect(results[0].error).to.include('All inventory movements failed');
|
|
2126
|
+
expect(results[0].error).to.include('arbitrum: bridge exploded');
|
|
2127
|
+
expect(executeInventoryMovementStub.calledOnce).to.be.true;
|
|
2128
|
+
expect(bridge.execute.called).to.be.false;
|
|
2129
|
+
|
|
2130
|
+
executeInventoryMovementStub.restore();
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
it('logs actual successful bridge output floors instead of planned output totals', async () => {
|
|
2134
|
+
const amount = BigInt(1e18);
|
|
2135
|
+
const perChainInventory = BigInt(0.6e18);
|
|
2136
|
+
const logger = {
|
|
2137
|
+
info: Sinon.stub(),
|
|
2138
|
+
debug: Sinon.stub(),
|
|
2139
|
+
warn: Sinon.stub(),
|
|
2140
|
+
error: Sinon.stub(),
|
|
2141
|
+
};
|
|
2142
|
+
|
|
2143
|
+
const localRebalancer = new InventoryRebalancer(
|
|
2144
|
+
config,
|
|
2145
|
+
actionTracker as unknown as IActionTracker,
|
|
2146
|
+
{ lifi: bridge as unknown as IExternalBridge },
|
|
2147
|
+
warpCore as unknown as WarpCore,
|
|
2148
|
+
multiProvider as unknown as MultiProvider,
|
|
2149
|
+
logger as unknown as Logger,
|
|
2150
|
+
);
|
|
2151
|
+
|
|
2152
|
+
const route = createTestRoute({ amount });
|
|
2153
|
+
createTestIntent({ amount });
|
|
2154
|
+
|
|
2155
|
+
localRebalancer.setInventoryBalances({
|
|
2156
|
+
[SOLANA_CHAIN]: 0n,
|
|
2157
|
+
[ARBITRUM_CHAIN]: perChainInventory,
|
|
2158
|
+
[BASE_CHAIN]: perChainInventory,
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
bridge.quote
|
|
2162
|
+
.onCall(0)
|
|
2163
|
+
.resolves(
|
|
2164
|
+
createMockBridgeQuote({
|
|
2165
|
+
fromAmount: perChainInventory,
|
|
2166
|
+
toAmount: BigInt(0.525e18),
|
|
2167
|
+
toAmountMin: BigInt(0.525e18),
|
|
2168
|
+
}),
|
|
2169
|
+
)
|
|
2170
|
+
.onCall(1)
|
|
2171
|
+
.resolves(
|
|
2172
|
+
createMockBridgeQuote({
|
|
2173
|
+
fromAmount: perChainInventory,
|
|
2174
|
+
toAmount: BigInt(0.525e18),
|
|
2175
|
+
toAmountMin: BigInt(0.525e18),
|
|
2176
|
+
}),
|
|
2177
|
+
)
|
|
2178
|
+
.onCall(2)
|
|
2179
|
+
.resolves(
|
|
2180
|
+
createMockBridgeQuote({
|
|
2181
|
+
fromAmount: perChainInventory,
|
|
2182
|
+
toAmount: BigInt(0.505e18),
|
|
2183
|
+
toAmountMin: BigInt(0.5e18),
|
|
2184
|
+
}),
|
|
2185
|
+
)
|
|
2186
|
+
.onCall(3)
|
|
2187
|
+
.resolves(
|
|
2188
|
+
createMockBridgeQuote({
|
|
2189
|
+
fromAmount: perChainInventory,
|
|
2190
|
+
toAmount: BigInt(0.515e18),
|
|
2191
|
+
toAmountMin: BigInt(0.51e18),
|
|
2192
|
+
}),
|
|
2193
|
+
);
|
|
2194
|
+
|
|
2195
|
+
bridge.execute
|
|
2196
|
+
.onFirstCall()
|
|
2197
|
+
.resolves({
|
|
2198
|
+
txHash: '0xSuccessTxHash1',
|
|
2199
|
+
fromChain: 42161,
|
|
2200
|
+
toChain: 1399811149,
|
|
2201
|
+
})
|
|
2202
|
+
.onSecondCall()
|
|
2203
|
+
.resolves({
|
|
2204
|
+
txHash: '0xSuccessTxHash2',
|
|
2205
|
+
fromChain: 8453,
|
|
2206
|
+
toChain: 1399811149,
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
const results = await localRebalancer.rebalance([route]);
|
|
2210
|
+
|
|
2211
|
+
expect(results).to.have.lengthOf(1);
|
|
2212
|
+
expect(results[0].success).to.be.true;
|
|
2213
|
+
expect(bridge.execute.callCount).to.equal(2);
|
|
2214
|
+
|
|
2215
|
+
const summaryCall = logger.info
|
|
2216
|
+
.getCalls()
|
|
2217
|
+
.find(
|
|
2218
|
+
(call: ReturnType<typeof logger.info.getCall>) =>
|
|
2219
|
+
call.args[0] &&
|
|
2220
|
+
typeof call.args[0] === 'object' &&
|
|
2221
|
+
Object.prototype.hasOwnProperty.call(
|
|
2222
|
+
call.args[0],
|
|
2223
|
+
'totalQuotedOutputMin',
|
|
2224
|
+
),
|
|
2225
|
+
);
|
|
2226
|
+
|
|
2227
|
+
assert(summaryCall, 'Expected summary log with totalQuotedOutputMin');
|
|
2228
|
+
expect(summaryCall.args[0].totalQuotedOutputMin).to.equal(
|
|
2229
|
+
BigInt(1.01e18).toString(),
|
|
2230
|
+
);
|
|
1497
2231
|
});
|
|
1498
2232
|
|
|
1499
2233
|
it('continues when some bridges fail', async () => {
|
|
@@ -1721,14 +2455,36 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1721
2455
|
});
|
|
1722
2456
|
|
|
1723
2457
|
// Quote with high gas costs (but for ERC20, this shouldn't trigger viability check)
|
|
1724
|
-
bridge.quote
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
2458
|
+
bridge.quote
|
|
2459
|
+
.onFirstCall()
|
|
2460
|
+
.resolves(
|
|
2461
|
+
createMockBridgeQuote({
|
|
2462
|
+
fromAmount: tokenBalance,
|
|
2463
|
+
toAmount: tokenBalance,
|
|
2464
|
+
toAmountMin: tokenBalance,
|
|
2465
|
+
gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
|
|
2466
|
+
feeCosts: 0n,
|
|
2467
|
+
}),
|
|
2468
|
+
)
|
|
2469
|
+
.onSecondCall()
|
|
2470
|
+
.resolves(
|
|
2471
|
+
createMockBridgeQuote({
|
|
2472
|
+
fromAmount: tokenBalance,
|
|
2473
|
+
toAmount: tokenBalance,
|
|
2474
|
+
toAmountMin: tokenBalance,
|
|
2475
|
+
gasCosts: BigInt(1e18),
|
|
2476
|
+
feeCosts: 0n,
|
|
2477
|
+
requestParams: {
|
|
2478
|
+
fromChain: 42161,
|
|
2479
|
+
toChain: 1399811149,
|
|
2480
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2481
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2482
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2483
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2484
|
+
toAmount: tokenBalance,
|
|
2485
|
+
},
|
|
2486
|
+
}),
|
|
2487
|
+
);
|
|
1732
2488
|
|
|
1733
2489
|
bridge.execute.resolves({
|
|
1734
2490
|
txHash: '0xERC20BridgeTxHash',
|
|
@@ -1742,6 +2498,9 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1742
2498
|
expect(results).to.have.lengthOf(1);
|
|
1743
2499
|
expect(results[0].success).to.be.true;
|
|
1744
2500
|
expect(bridge.execute.calledOnce).to.be.true;
|
|
2501
|
+
expect(bridge.quote.callCount).to.equal(2);
|
|
2502
|
+
expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(tokenBalance);
|
|
2503
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
|
|
1745
2504
|
});
|
|
1746
2505
|
|
|
1747
2506
|
it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
|
|
@@ -1777,14 +2536,47 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1777
2536
|
// maxViable = 1 ETH - 0.02 ETH = 0.98 ETH
|
|
1778
2537
|
// 10% threshold = 0.1 ETH, estimatedGas (0.02) < threshold → viable
|
|
1779
2538
|
const gasCosts = BigInt(0.001e18); // 0.001 ETH
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
2539
|
+
const maxViable = rawBalance - gasCosts * 20n;
|
|
2540
|
+
bridge.quote
|
|
2541
|
+
.onFirstCall()
|
|
2542
|
+
.resolves(
|
|
2543
|
+
createMockBridgeQuote({
|
|
2544
|
+
fromAmount: rawBalance,
|
|
2545
|
+
toAmount: rawBalance - BigInt(1e15),
|
|
2546
|
+
toAmountMin: rawBalance - BigInt(1e15),
|
|
2547
|
+
gasCosts,
|
|
2548
|
+
feeCosts: 0n,
|
|
2549
|
+
}),
|
|
2550
|
+
)
|
|
2551
|
+
.onSecondCall()
|
|
2552
|
+
.resolves(
|
|
2553
|
+
createMockBridgeQuote({
|
|
2554
|
+
fromAmount: maxViable,
|
|
2555
|
+
toAmount: maxViable - BigInt(1e15),
|
|
2556
|
+
toAmountMin: maxViable - BigInt(1e15),
|
|
2557
|
+
gasCosts,
|
|
2558
|
+
feeCosts: 0n,
|
|
2559
|
+
}),
|
|
2560
|
+
)
|
|
2561
|
+
.onThirdCall()
|
|
2562
|
+
.resolves(
|
|
2563
|
+
createMockBridgeQuote({
|
|
2564
|
+
fromAmount: BigInt(0.525e18),
|
|
2565
|
+
toAmount: BigInt(0.525e18),
|
|
2566
|
+
toAmountMin: BigInt(0.525e18),
|
|
2567
|
+
gasCosts,
|
|
2568
|
+
feeCosts: 0n,
|
|
2569
|
+
requestParams: {
|
|
2570
|
+
fromChain: 42161,
|
|
2571
|
+
toChain: 1399811149,
|
|
2572
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2573
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2574
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2575
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2576
|
+
toAmount: BigInt(0.525e18),
|
|
2577
|
+
},
|
|
2578
|
+
}),
|
|
2579
|
+
);
|
|
1788
2580
|
|
|
1789
2581
|
bridge.execute.resolves({
|
|
1790
2582
|
txHash: '0xSuccessBridgeTxHash',
|
|
@@ -1798,19 +2590,121 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1798
2590
|
expect(results).to.have.lengthOf(1);
|
|
1799
2591
|
expect(results[0].success).to.be.true;
|
|
1800
2592
|
expect(bridge.execute.calledOnce).to.be.true;
|
|
2593
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
2594
|
+
|
|
2595
|
+
expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(maxViable);
|
|
2596
|
+
const executionTargetOutput = bridge.quote.getCall(2).args[0].toAmount;
|
|
2597
|
+
expect(executionTargetOutput).to.be.a('bigint');
|
|
2598
|
+
if (executionTargetOutput === undefined) {
|
|
2599
|
+
throw new Error('Expected reverse quote to set toAmount');
|
|
2600
|
+
}
|
|
2601
|
+
expect(executionTargetOutput > amount).to.be.true;
|
|
2602
|
+
expect(executionTargetOutput <= maxViable).to.be.true;
|
|
2603
|
+
});
|
|
2604
|
+
|
|
2605
|
+
it('uses forward quote when target output exactly matches source capacity', async () => {
|
|
2606
|
+
const amount = 1000n;
|
|
2607
|
+
const rawBalance = 1100n;
|
|
2608
|
+
const targetWithBuffer = 1050n;
|
|
2609
|
+
|
|
2610
|
+
const route = createTestRoute({ amount });
|
|
2611
|
+
createTestIntent({ amount });
|
|
2612
|
+
|
|
2613
|
+
inventoryRebalancer.setInventoryBalances({
|
|
2614
|
+
[SOLANA_CHAIN]: 0n,
|
|
2615
|
+
[ARBITRUM_CHAIN]: rawBalance,
|
|
2616
|
+
});
|
|
2617
|
+
|
|
2618
|
+
bridge.quote
|
|
2619
|
+
.onFirstCall()
|
|
2620
|
+
.resolves(
|
|
2621
|
+
createMockBridgeQuote({
|
|
2622
|
+
fromAmount: rawBalance,
|
|
2623
|
+
toAmount: targetWithBuffer,
|
|
2624
|
+
toAmountMin: targetWithBuffer,
|
|
2625
|
+
}),
|
|
2626
|
+
)
|
|
2627
|
+
.onSecondCall()
|
|
2628
|
+
.resolves(
|
|
2629
|
+
createMockBridgeQuote({
|
|
2630
|
+
fromAmount: rawBalance,
|
|
2631
|
+
toAmount: targetWithBuffer,
|
|
2632
|
+
toAmountMin: targetWithBuffer,
|
|
2633
|
+
}),
|
|
2634
|
+
);
|
|
2635
|
+
|
|
2636
|
+
bridge.execute.resolves({
|
|
2637
|
+
txHash: '0xForwardBoundaryBridgeTxHash',
|
|
2638
|
+
fromChain: 42161,
|
|
2639
|
+
toChain: 1399811149,
|
|
2640
|
+
});
|
|
2641
|
+
|
|
2642
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
2643
|
+
|
|
2644
|
+
expect(results).to.have.lengthOf(1);
|
|
2645
|
+
expect(results[0].success).to.be.true;
|
|
2646
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
2647
|
+
expect(bridge.quote.callCount).to.equal(2);
|
|
2648
|
+
expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(rawBalance);
|
|
2649
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
|
|
2650
|
+
});
|
|
1801
2651
|
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
const
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2652
|
+
it('retries reverse quote as forward when exact-output exceeds source capacity', async () => {
|
|
2653
|
+
const amount = 1000n;
|
|
2654
|
+
const rawBalance = 1100n;
|
|
2655
|
+
const targetWithBuffer = 1050n;
|
|
2656
|
+
|
|
2657
|
+
const route = createTestRoute({ amount });
|
|
2658
|
+
createTestIntent({ amount });
|
|
2659
|
+
|
|
2660
|
+
inventoryRebalancer.setInventoryBalances({
|
|
2661
|
+
[SOLANA_CHAIN]: 0n,
|
|
2662
|
+
[ARBITRUM_CHAIN]: rawBalance,
|
|
2663
|
+
});
|
|
2664
|
+
|
|
2665
|
+
bridge.quote
|
|
2666
|
+
.onFirstCall()
|
|
2667
|
+
.resolves(
|
|
2668
|
+
createMockBridgeQuote({
|
|
2669
|
+
fromAmount: rawBalance,
|
|
2670
|
+
toAmount: 1051n,
|
|
2671
|
+
toAmountMin: 1051n,
|
|
2672
|
+
}),
|
|
2673
|
+
)
|
|
2674
|
+
.onSecondCall()
|
|
2675
|
+
.resolves(
|
|
2676
|
+
createMockBridgeQuote({
|
|
2677
|
+
fromAmount: rawBalance + 1n,
|
|
2678
|
+
toAmount: targetWithBuffer,
|
|
2679
|
+
toAmountMin: targetWithBuffer,
|
|
2680
|
+
}),
|
|
2681
|
+
)
|
|
2682
|
+
.onThirdCall()
|
|
2683
|
+
.resolves(
|
|
2684
|
+
createMockBridgeQuote({
|
|
2685
|
+
fromAmount: rawBalance,
|
|
2686
|
+
toAmount: 1049n,
|
|
2687
|
+
toAmountMin: 1049n,
|
|
2688
|
+
}),
|
|
1811
2689
|
);
|
|
1812
|
-
|
|
1813
|
-
|
|
2690
|
+
|
|
2691
|
+
bridge.execute.resolves({
|
|
2692
|
+
txHash: '0xForwardFallbackBridgeTxHash',
|
|
2693
|
+
fromChain: 42161,
|
|
2694
|
+
toChain: 1399811149,
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2697
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
2698
|
+
|
|
2699
|
+
expect(results).to.have.lengthOf(1);
|
|
2700
|
+
expect(results[0].success).to.be.true;
|
|
2701
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
2702
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
2703
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(
|
|
2704
|
+
targetWithBuffer,
|
|
2705
|
+
);
|
|
2706
|
+
expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(rawBalance);
|
|
2707
|
+
expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
|
|
1814
2708
|
});
|
|
1815
2709
|
|
|
1816
2710
|
it('handles quote failures gracefully by skipping the source chain', async () => {
|