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