@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
|
@@ -3,8 +3,8 @@ import chaiAsPromised from 'chai-as-promised';
|
|
|
3
3
|
import { Wallet } from 'ethers';
|
|
4
4
|
import { pino } from 'pino';
|
|
5
5
|
import Sinon from 'sinon';
|
|
6
|
-
import { ProviderType, TokenStandard, WarpTxCategory, } from '@hyperlane-xyz/sdk';
|
|
7
|
-
import { ProtocolType } from '@hyperlane-xyz/utils';
|
|
6
|
+
import { HyperlaneCore, ProviderType, TokenStandard, WarpTxCategory, } from '@hyperlane-xyz/sdk';
|
|
7
|
+
import { assert, ProtocolType } from '@hyperlane-xyz/utils';
|
|
8
8
|
import { ExternalBridgeType } from '../config/types.js';
|
|
9
9
|
import { createMockBridgeQuote } from '../test/lifiMocks.js';
|
|
10
10
|
import { InventoryRebalancer, } from './InventoryRebalancer.js';
|
|
@@ -356,6 +356,113 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
356
356
|
expect(actionParams.amount).to.equal(1000000n);
|
|
357
357
|
});
|
|
358
358
|
});
|
|
359
|
+
it('sendAndConfirmInventoryTx uses the EVM-like signer path for Tron txs', async () => {
|
|
360
|
+
const TRON_CHAIN = 'tron';
|
|
361
|
+
config.inventorySigners[ProtocolType.Tron] = {
|
|
362
|
+
address: INVENTORY_SIGNER,
|
|
363
|
+
key: TEST_PRIVATE_KEY,
|
|
364
|
+
};
|
|
365
|
+
const getChainMetadata = (chain) => ({
|
|
366
|
+
name: chain,
|
|
367
|
+
protocol: chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
|
|
368
|
+
blocks: { reorgPeriod: 2 },
|
|
369
|
+
rpcUrls: [{ http: 'http://127.0.0.1:9090/jsonrpc' }],
|
|
370
|
+
});
|
|
371
|
+
warpCore.multiProvider.getChainMetadata.callsFake(getChainMetadata);
|
|
372
|
+
multiProvider.getChainMetadata.callsFake(getChainMetadata);
|
|
373
|
+
warpCore.multiProvider.toMultiProvider =
|
|
374
|
+
Sinon.stub().returns(multiProvider);
|
|
375
|
+
const sendFn = inventoryRebalancer.sendAndConfirmInventoryTx.bind(inventoryRebalancer);
|
|
376
|
+
const result = await sendFn(TRON_CHAIN, {
|
|
377
|
+
category: WarpTxCategory.Transfer,
|
|
378
|
+
type: ProviderType.Tron,
|
|
379
|
+
transaction: {
|
|
380
|
+
to: '0xRouterAddress',
|
|
381
|
+
data: '0xTransferRemoteData',
|
|
382
|
+
value: 1000000n,
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
expect(result).to.deep.equal({ txHash: '0xTransferRemoteTxHash' });
|
|
386
|
+
expect(multiProvider.setSigner.calledOnce).to.be.true;
|
|
387
|
+
expect(multiProvider.sendTransaction.calledOnceWithExactly(TRON_CHAIN, {
|
|
388
|
+
to: '0xRouterAddress',
|
|
389
|
+
data: '0xTransferRemoteData',
|
|
390
|
+
value: 1000000n,
|
|
391
|
+
}, { waitConfirmations: 2 })).to.be.true;
|
|
392
|
+
});
|
|
393
|
+
it('buildSignerAccountConfig returns correct config for Tron protocol', () => {
|
|
394
|
+
config.inventorySigners[ProtocolType.Tron] = {
|
|
395
|
+
address: INVENTORY_SIGNER,
|
|
396
|
+
key: TEST_PRIVATE_KEY,
|
|
397
|
+
};
|
|
398
|
+
const buildFn = inventoryRebalancer.buildSignerAccountConfig.bind(inventoryRebalancer);
|
|
399
|
+
const result = buildFn(ProtocolType.Tron, TEST_PRIVATE_KEY, 'tron');
|
|
400
|
+
expect(result.protocol).to.equal(ProtocolType.Tron);
|
|
401
|
+
expect(result.privateKey).to.equal(TEST_PRIVATE_KEY);
|
|
402
|
+
// Tron uses same 0x-prefixed hex key format as Ethereum
|
|
403
|
+
expect(result.privateKey.startsWith('0x')).to.be.true;
|
|
404
|
+
});
|
|
405
|
+
it('getTransactionReceipt returns EthersV5 type for Tron chain', async () => {
|
|
406
|
+
const TRON_CHAIN = 'tron';
|
|
407
|
+
const mockReceipt = {
|
|
408
|
+
transactionHash: '0xTronReceipt',
|
|
409
|
+
logs: [],
|
|
410
|
+
};
|
|
411
|
+
warpCore.multiProvider.getChainMetadata.callsFake((chain) => ({
|
|
412
|
+
name: chain,
|
|
413
|
+
protocol: chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
|
|
414
|
+
}));
|
|
415
|
+
warpCore.multiProvider.getEthersV5Provider.returns({
|
|
416
|
+
getTransactionReceipt: Sinon.stub().resolves(mockReceipt),
|
|
417
|
+
});
|
|
418
|
+
const getReceiptFn = inventoryRebalancer.getTransactionReceipt.bind(inventoryRebalancer);
|
|
419
|
+
const receipt = await getReceiptFn(TRON_CHAIN, '0xTronTxHash');
|
|
420
|
+
expect(receipt).to.not.be.undefined;
|
|
421
|
+
expect(receipt.type).to.equal(ProviderType.EthersV5);
|
|
422
|
+
expect(receipt.receipt).to.deep.equal(mockReceipt);
|
|
423
|
+
});
|
|
424
|
+
it('extractDispatchedMessageId returns Tron message ids via the EthersV5 path', async () => {
|
|
425
|
+
const TRON_CHAIN = 'tron';
|
|
426
|
+
const expectedMessageId = '0x1111111111111111111111111111111111111111111111111111111111111111';
|
|
427
|
+
const mockReceipt = {
|
|
428
|
+
transactionHash: '0xTronTx',
|
|
429
|
+
logs: [{}],
|
|
430
|
+
};
|
|
431
|
+
warpCore.multiProvider.getChainMetadata.callsFake((chain) => ({
|
|
432
|
+
name: chain,
|
|
433
|
+
protocol: chain === TRON_CHAIN ? ProtocolType.Tron : ProtocolType.Ethereum,
|
|
434
|
+
}));
|
|
435
|
+
const getDispatchedMessagesStub = Sinon.stub(HyperlaneCore, 'getDispatchedMessages').returns([{ id: expectedMessageId }]);
|
|
436
|
+
warpCore.multiProvider.getEthersV5Provider.returns({
|
|
437
|
+
getTransactionReceipt: Sinon.stub().resolves(mockReceipt),
|
|
438
|
+
});
|
|
439
|
+
const extractFn = inventoryRebalancer.extractDispatchedMessageId.bind(inventoryRebalancer);
|
|
440
|
+
const messageId = await extractFn(TRON_CHAIN, '0xTronTx');
|
|
441
|
+
expect(messageId).to.equal(expectedMessageId);
|
|
442
|
+
expect(getDispatchedMessagesStub.calledOnce).to.be.true;
|
|
443
|
+
expect(getDispatchedMessagesStub.firstCall.firstArg).to.equal(mockReceipt);
|
|
444
|
+
});
|
|
445
|
+
it('selects the Tron signer address from a mixed multi-protocol config', () => {
|
|
446
|
+
config.inventorySigners[ProtocolType.Tron] = {
|
|
447
|
+
address: INVENTORY_SIGNER,
|
|
448
|
+
key: TEST_PRIVATE_KEY,
|
|
449
|
+
};
|
|
450
|
+
config.inventorySigners[ProtocolType.Sealevel] = {
|
|
451
|
+
address: 'SoLANAAddReSs1234567890123456789012345678',
|
|
452
|
+
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',
|
|
453
|
+
};
|
|
454
|
+
warpCore.multiProvider.getChainMetadata.callsFake((chain) => ({
|
|
455
|
+
name: chain,
|
|
456
|
+
protocol: chain === 'tron'
|
|
457
|
+
? ProtocolType.Tron
|
|
458
|
+
: chain === SOLANA_CHAIN
|
|
459
|
+
? ProtocolType.Sealevel
|
|
460
|
+
: ProtocolType.Ethereum,
|
|
461
|
+
}));
|
|
462
|
+
const getInventorySignerAddress = inventoryRebalancer.getInventorySignerAddress.bind(inventoryRebalancer);
|
|
463
|
+
expect(getInventorySignerAddress('tron')).to.equal(INVENTORY_SIGNER);
|
|
464
|
+
expect(getInventorySignerAddress(SOLANA_CHAIN)).to.equal('SoLANAAddReSs1234567890123456789012345678');
|
|
465
|
+
});
|
|
359
466
|
describe('Partial Fulfillment (Insufficient Inventory)', () => {
|
|
360
467
|
// Partial transfers happen when maxTransferable >= minViableTransfer.
|
|
361
468
|
// For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
|
|
@@ -1373,22 +1480,20 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1373
1480
|
expect(results).to.have.lengthOf(1);
|
|
1374
1481
|
expect(results[0].success).to.be.true;
|
|
1375
1482
|
expect(bridge.execute.callCount).to.equal(2);
|
|
1376
|
-
const reverseQuoteRequests = bridge.quote
|
|
1377
|
-
.getCalls()
|
|
1378
|
-
.map((call) => call.args[0])
|
|
1379
|
-
.filter((params) => params.toAmount !== undefined);
|
|
1380
|
-
expect(reverseQuoteRequests).to.have.lengthOf(2);
|
|
1381
|
-
const requestedOutputs = reverseQuoteRequests
|
|
1382
|
-
.map((params) => params.toAmount)
|
|
1383
|
-
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1384
1483
|
const maxPerSourceOutput = perChainInventory - reservedGas;
|
|
1385
|
-
const
|
|
1386
|
-
|
|
1387
|
-
.
|
|
1388
|
-
|
|
1389
|
-
expect(
|
|
1390
|
-
|
|
1391
|
-
|
|
1484
|
+
const executionQuoteRequests = bridge.quote
|
|
1485
|
+
.getCalls()
|
|
1486
|
+
.slice(-2)
|
|
1487
|
+
.map((call) => call.args[0]);
|
|
1488
|
+
expect(executionQuoteRequests).to.have.lengthOf(2);
|
|
1489
|
+
const forwardQuoteRequests = executionQuoteRequests.filter((params) => params.fromAmount !== undefined);
|
|
1490
|
+
const reverseQuoteRequests = executionQuoteRequests.filter((params) => params.toAmount !== undefined);
|
|
1491
|
+
const forwardAmounts = forwardQuoteRequests.map((params) => params.fromAmount);
|
|
1492
|
+
expect(forwardQuoteRequests.length + reverseQuoteRequests.length).to.equal(2);
|
|
1493
|
+
expect(forwardQuoteRequests.length).to.be.greaterThan(0);
|
|
1494
|
+
expect(forwardAmounts.every((amount) => amount <= maxPerSourceOutput)).to
|
|
1495
|
+
.be.true;
|
|
1496
|
+
expect(reverseQuoteRequests.every((params) => params.toAmount <= maxPerSourceOutput)).to.be.true;
|
|
1392
1497
|
});
|
|
1393
1498
|
it('applies 5% buffer to total bridge amount', async () => {
|
|
1394
1499
|
// Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
|
|
@@ -1500,7 +1605,7 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1500
1605
|
await inventoryRebalancer.rebalance([route]);
|
|
1501
1606
|
expect(quotedTargetOutput).to.equal(1050000000000000000n);
|
|
1502
1607
|
});
|
|
1503
|
-
it('
|
|
1608
|
+
it('retries forward execution when reverse quote exceeds planned source capacity', async () => {
|
|
1504
1609
|
const amount = BigInt(1e18);
|
|
1505
1610
|
const sourceInventory = BigInt(2e18);
|
|
1506
1611
|
const targetWithBuffer = (amount * 105n) / 100n;
|
|
@@ -1540,13 +1645,241 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1540
1645
|
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1541
1646
|
toAmount: targetWithBuffer,
|
|
1542
1647
|
},
|
|
1648
|
+
}))
|
|
1649
|
+
.onThirdCall()
|
|
1650
|
+
.resolves(createMockBridgeQuote({
|
|
1651
|
+
fromAmount: sourceInventory,
|
|
1652
|
+
toAmount: targetWithBuffer - 1n,
|
|
1653
|
+
toAmountMin: targetWithBuffer - 1n,
|
|
1654
|
+
requestParams: {
|
|
1655
|
+
fromChain: 42161,
|
|
1656
|
+
toChain: 1399811149,
|
|
1657
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1658
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1659
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1660
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1661
|
+
fromAmount: sourceInventory,
|
|
1662
|
+
},
|
|
1663
|
+
}));
|
|
1664
|
+
bridge.execute.resolves({
|
|
1665
|
+
txHash: '0xBridgeTxHash',
|
|
1666
|
+
fromChain: 42161,
|
|
1667
|
+
toChain: 1399811149,
|
|
1668
|
+
});
|
|
1669
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1670
|
+
expect(results).to.have.lengthOf(1);
|
|
1671
|
+
expect(results[0].success).to.be.true;
|
|
1672
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
1673
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
1674
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
|
|
1675
|
+
expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(sourceInventory);
|
|
1676
|
+
expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
|
|
1677
|
+
});
|
|
1678
|
+
it('fails when forward retry still exceeds planned source capacity', async () => {
|
|
1679
|
+
const amount = BigInt(1e18);
|
|
1680
|
+
const sourceInventory = BigInt(2e18);
|
|
1681
|
+
const targetWithBuffer = (amount * 105n) / 100n;
|
|
1682
|
+
const route = createTestRoute({ amount });
|
|
1683
|
+
createTestIntent({ amount });
|
|
1684
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1685
|
+
[SOLANA_CHAIN]: 0n,
|
|
1686
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1687
|
+
});
|
|
1688
|
+
bridge.quote
|
|
1689
|
+
.onFirstCall()
|
|
1690
|
+
.resolves(createMockBridgeQuote({
|
|
1691
|
+
fromAmount: sourceInventory,
|
|
1692
|
+
toAmount: sourceInventory,
|
|
1693
|
+
toAmountMin: sourceInventory,
|
|
1694
|
+
requestParams: {
|
|
1695
|
+
fromChain: 42161,
|
|
1696
|
+
toChain: 1399811149,
|
|
1697
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1698
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1699
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1700
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1701
|
+
fromAmount: sourceInventory,
|
|
1702
|
+
},
|
|
1703
|
+
}))
|
|
1704
|
+
.onSecondCall()
|
|
1705
|
+
.resolves(createMockBridgeQuote({
|
|
1706
|
+
fromAmount: sourceInventory + 1n,
|
|
1707
|
+
toAmount: targetWithBuffer,
|
|
1708
|
+
toAmountMin: targetWithBuffer,
|
|
1709
|
+
requestParams: {
|
|
1710
|
+
fromChain: 42161,
|
|
1711
|
+
toChain: 1399811149,
|
|
1712
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1713
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1714
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1715
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1716
|
+
toAmount: targetWithBuffer,
|
|
1717
|
+
},
|
|
1718
|
+
}))
|
|
1719
|
+
.onThirdCall()
|
|
1720
|
+
.resolves(createMockBridgeQuote({
|
|
1721
|
+
fromAmount: sourceInventory + 1n,
|
|
1722
|
+
toAmount: targetWithBuffer - 1n,
|
|
1723
|
+
toAmountMin: targetWithBuffer - 1n,
|
|
1724
|
+
requestParams: {
|
|
1725
|
+
fromChain: 42161,
|
|
1726
|
+
toChain: 1399811149,
|
|
1727
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1728
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1729
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1730
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1731
|
+
fromAmount: sourceInventory,
|
|
1732
|
+
},
|
|
1543
1733
|
}));
|
|
1544
1734
|
const results = await inventoryRebalancer.rebalance([route]);
|
|
1545
1735
|
expect(results).to.have.lengthOf(1);
|
|
1546
1736
|
expect(results[0].success).to.be.false;
|
|
1547
1737
|
expect(results[0].error).to.include('All inventory movements failed');
|
|
1548
1738
|
expect(results[0].error).to.include('exceeded planned source capacity');
|
|
1739
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
1549
1740
|
expect(bridge.execute.called).to.be.false;
|
|
1741
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
|
|
1742
|
+
expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(sourceInventory);
|
|
1743
|
+
expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
|
|
1744
|
+
});
|
|
1745
|
+
it('fails gracefully when execute-time bridge quote rejects', async () => {
|
|
1746
|
+
const amount = BigInt(1e18);
|
|
1747
|
+
const sourceInventory = BigInt(2e18);
|
|
1748
|
+
const route = createTestRoute({ amount });
|
|
1749
|
+
createTestIntent({ amount });
|
|
1750
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1751
|
+
[SOLANA_CHAIN]: 0n,
|
|
1752
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1753
|
+
});
|
|
1754
|
+
bridge.quote
|
|
1755
|
+
.onFirstCall()
|
|
1756
|
+
.resolves(createMockBridgeQuote({
|
|
1757
|
+
fromAmount: sourceInventory,
|
|
1758
|
+
toAmount: sourceInventory,
|
|
1759
|
+
toAmountMin: sourceInventory,
|
|
1760
|
+
requestParams: {
|
|
1761
|
+
fromChain: 42161,
|
|
1762
|
+
toChain: 1399811149,
|
|
1763
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1764
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1765
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1766
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1767
|
+
fromAmount: sourceInventory,
|
|
1768
|
+
},
|
|
1769
|
+
}))
|
|
1770
|
+
.onSecondCall()
|
|
1771
|
+
.rejects(new Error('LiFi API timeout'));
|
|
1772
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1773
|
+
expect(results).to.have.lengthOf(1);
|
|
1774
|
+
expect(results[0].success).to.be.false;
|
|
1775
|
+
expect(results[0].error).to.include('All inventory movements failed');
|
|
1776
|
+
expect(results[0].error).to.include('LiFi API timeout');
|
|
1777
|
+
expect(bridge.quote.callCount).to.equal(2);
|
|
1778
|
+
expect(bridge.execute.called).to.be.false;
|
|
1779
|
+
});
|
|
1780
|
+
it('preserves non-Error rejection reasons from parallel bridge execution', async () => {
|
|
1781
|
+
const amount = BigInt(1e18);
|
|
1782
|
+
const sourceInventory = BigInt(2e18);
|
|
1783
|
+
const route = createTestRoute({ amount });
|
|
1784
|
+
createTestIntent({ amount });
|
|
1785
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1786
|
+
[SOLANA_CHAIN]: 0n,
|
|
1787
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1788
|
+
});
|
|
1789
|
+
bridge.quote.onFirstCall().resolves(createMockBridgeQuote({
|
|
1790
|
+
fromAmount: sourceInventory,
|
|
1791
|
+
toAmount: sourceInventory,
|
|
1792
|
+
toAmountMin: sourceInventory,
|
|
1793
|
+
requestParams: {
|
|
1794
|
+
fromChain: 42161,
|
|
1795
|
+
toChain: 1399811149,
|
|
1796
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1797
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1798
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1799
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1800
|
+
fromAmount: sourceInventory,
|
|
1801
|
+
},
|
|
1802
|
+
}));
|
|
1803
|
+
// Simulate a foreign promise rejecting with a non-Error reason at runtime.
|
|
1804
|
+
const rejectionReason = 'bridge exploded';
|
|
1805
|
+
const executeInventoryMovementStub = Sinon.stub(inventoryRebalancer, 'executeInventoryMovement').callsFake(() => Promise.resolve().then(() => {
|
|
1806
|
+
throw rejectionReason;
|
|
1807
|
+
}));
|
|
1808
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1809
|
+
expect(results).to.have.lengthOf(1);
|
|
1810
|
+
expect(results[0].success).to.be.false;
|
|
1811
|
+
expect(results[0].error).to.include('All inventory movements failed');
|
|
1812
|
+
expect(results[0].error).to.include('arbitrum: bridge exploded');
|
|
1813
|
+
expect(executeInventoryMovementStub.calledOnce).to.be.true;
|
|
1814
|
+
expect(bridge.execute.called).to.be.false;
|
|
1815
|
+
executeInventoryMovementStub.restore();
|
|
1816
|
+
});
|
|
1817
|
+
it('logs actual successful bridge output floors instead of planned output totals', async () => {
|
|
1818
|
+
const amount = BigInt(1e18);
|
|
1819
|
+
const perChainInventory = BigInt(0.6e18);
|
|
1820
|
+
const logger = {
|
|
1821
|
+
info: Sinon.stub(),
|
|
1822
|
+
debug: Sinon.stub(),
|
|
1823
|
+
warn: Sinon.stub(),
|
|
1824
|
+
error: Sinon.stub(),
|
|
1825
|
+
};
|
|
1826
|
+
const localRebalancer = new InventoryRebalancer(config, actionTracker, { lifi: bridge }, warpCore, multiProvider, logger);
|
|
1827
|
+
const route = createTestRoute({ amount });
|
|
1828
|
+
createTestIntent({ amount });
|
|
1829
|
+
localRebalancer.setInventoryBalances({
|
|
1830
|
+
[SOLANA_CHAIN]: 0n,
|
|
1831
|
+
[ARBITRUM_CHAIN]: perChainInventory,
|
|
1832
|
+
[BASE_CHAIN]: perChainInventory,
|
|
1833
|
+
});
|
|
1834
|
+
bridge.quote
|
|
1835
|
+
.onCall(0)
|
|
1836
|
+
.resolves(createMockBridgeQuote({
|
|
1837
|
+
fromAmount: perChainInventory,
|
|
1838
|
+
toAmount: BigInt(0.525e18),
|
|
1839
|
+
toAmountMin: BigInt(0.525e18),
|
|
1840
|
+
}))
|
|
1841
|
+
.onCall(1)
|
|
1842
|
+
.resolves(createMockBridgeQuote({
|
|
1843
|
+
fromAmount: perChainInventory,
|
|
1844
|
+
toAmount: BigInt(0.525e18),
|
|
1845
|
+
toAmountMin: BigInt(0.525e18),
|
|
1846
|
+
}))
|
|
1847
|
+
.onCall(2)
|
|
1848
|
+
.resolves(createMockBridgeQuote({
|
|
1849
|
+
fromAmount: perChainInventory,
|
|
1850
|
+
toAmount: BigInt(0.505e18),
|
|
1851
|
+
toAmountMin: BigInt(0.5e18),
|
|
1852
|
+
}))
|
|
1853
|
+
.onCall(3)
|
|
1854
|
+
.resolves(createMockBridgeQuote({
|
|
1855
|
+
fromAmount: perChainInventory,
|
|
1856
|
+
toAmount: BigInt(0.515e18),
|
|
1857
|
+
toAmountMin: BigInt(0.51e18),
|
|
1858
|
+
}));
|
|
1859
|
+
bridge.execute
|
|
1860
|
+
.onFirstCall()
|
|
1861
|
+
.resolves({
|
|
1862
|
+
txHash: '0xSuccessTxHash1',
|
|
1863
|
+
fromChain: 42161,
|
|
1864
|
+
toChain: 1399811149,
|
|
1865
|
+
})
|
|
1866
|
+
.onSecondCall()
|
|
1867
|
+
.resolves({
|
|
1868
|
+
txHash: '0xSuccessTxHash2',
|
|
1869
|
+
fromChain: 8453,
|
|
1870
|
+
toChain: 1399811149,
|
|
1871
|
+
});
|
|
1872
|
+
const results = await localRebalancer.rebalance([route]);
|
|
1873
|
+
expect(results).to.have.lengthOf(1);
|
|
1874
|
+
expect(results[0].success).to.be.true;
|
|
1875
|
+
expect(bridge.execute.callCount).to.equal(2);
|
|
1876
|
+
const summaryCall = logger.info
|
|
1877
|
+
.getCalls()
|
|
1878
|
+
.find((call) => call.args[0] &&
|
|
1879
|
+
typeof call.args[0] === 'object' &&
|
|
1880
|
+
Object.prototype.hasOwnProperty.call(call.args[0], 'totalQuotedOutputMin'));
|
|
1881
|
+
assert(summaryCall, 'Expected summary log with totalQuotedOutputMin');
|
|
1882
|
+
expect(summaryCall.args[0].totalQuotedOutputMin).to.equal(BigInt(1.01e18).toString());
|
|
1550
1883
|
});
|
|
1551
1884
|
it('continues when some bridges fail', async () => {
|
|
1552
1885
|
const amount = BigInt(1e18);
|
|
@@ -1763,7 +2096,9 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1763
2096
|
expect(results).to.have.lengthOf(1);
|
|
1764
2097
|
expect(results[0].success).to.be.true;
|
|
1765
2098
|
expect(bridge.execute.calledOnce).to.be.true;
|
|
1766
|
-
expect(bridge.quote.
|
|
2099
|
+
expect(bridge.quote.callCount).to.equal(2);
|
|
2100
|
+
expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(tokenBalance);
|
|
2101
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
|
|
1767
2102
|
});
|
|
1768
2103
|
it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
|
|
1769
2104
|
// Setup: Native token with enough balance for viable bridge
|
|
@@ -1838,8 +2173,9 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1838
2173
|
expect(results).to.have.lengthOf(1);
|
|
1839
2174
|
expect(results[0].success).to.be.true;
|
|
1840
2175
|
expect(bridge.execute.calledOnce).to.be.true;
|
|
1841
|
-
expect(bridge.quote.
|
|
1842
|
-
|
|
2176
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
2177
|
+
expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(maxViable);
|
|
2178
|
+
const executionTargetOutput = bridge.quote.getCall(2).args[0].toAmount;
|
|
1843
2179
|
expect(executionTargetOutput).to.be.a('bigint');
|
|
1844
2180
|
if (executionTargetOutput === undefined) {
|
|
1845
2181
|
throw new Error('Expected reverse quote to set toAmount');
|
|
@@ -1847,6 +2183,85 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1847
2183
|
expect(executionTargetOutput > amount).to.be.true;
|
|
1848
2184
|
expect(executionTargetOutput <= maxViable).to.be.true;
|
|
1849
2185
|
});
|
|
2186
|
+
it('uses forward quote when target output exactly matches source capacity', async () => {
|
|
2187
|
+
const amount = 1000n;
|
|
2188
|
+
const rawBalance = 1100n;
|
|
2189
|
+
const targetWithBuffer = 1050n;
|
|
2190
|
+
const route = createTestRoute({ amount });
|
|
2191
|
+
createTestIntent({ amount });
|
|
2192
|
+
inventoryRebalancer.setInventoryBalances({
|
|
2193
|
+
[SOLANA_CHAIN]: 0n,
|
|
2194
|
+
[ARBITRUM_CHAIN]: rawBalance,
|
|
2195
|
+
});
|
|
2196
|
+
bridge.quote
|
|
2197
|
+
.onFirstCall()
|
|
2198
|
+
.resolves(createMockBridgeQuote({
|
|
2199
|
+
fromAmount: rawBalance,
|
|
2200
|
+
toAmount: targetWithBuffer,
|
|
2201
|
+
toAmountMin: targetWithBuffer,
|
|
2202
|
+
}))
|
|
2203
|
+
.onSecondCall()
|
|
2204
|
+
.resolves(createMockBridgeQuote({
|
|
2205
|
+
fromAmount: rawBalance,
|
|
2206
|
+
toAmount: targetWithBuffer,
|
|
2207
|
+
toAmountMin: targetWithBuffer,
|
|
2208
|
+
}));
|
|
2209
|
+
bridge.execute.resolves({
|
|
2210
|
+
txHash: '0xForwardBoundaryBridgeTxHash',
|
|
2211
|
+
fromChain: 42161,
|
|
2212
|
+
toChain: 1399811149,
|
|
2213
|
+
});
|
|
2214
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
2215
|
+
expect(results).to.have.lengthOf(1);
|
|
2216
|
+
expect(results[0].success).to.be.true;
|
|
2217
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
2218
|
+
expect(bridge.quote.callCount).to.equal(2);
|
|
2219
|
+
expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(rawBalance);
|
|
2220
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
|
|
2221
|
+
});
|
|
2222
|
+
it('retries reverse quote as forward when exact-output exceeds source capacity', async () => {
|
|
2223
|
+
const amount = 1000n;
|
|
2224
|
+
const rawBalance = 1100n;
|
|
2225
|
+
const targetWithBuffer = 1050n;
|
|
2226
|
+
const route = createTestRoute({ amount });
|
|
2227
|
+
createTestIntent({ amount });
|
|
2228
|
+
inventoryRebalancer.setInventoryBalances({
|
|
2229
|
+
[SOLANA_CHAIN]: 0n,
|
|
2230
|
+
[ARBITRUM_CHAIN]: rawBalance,
|
|
2231
|
+
});
|
|
2232
|
+
bridge.quote
|
|
2233
|
+
.onFirstCall()
|
|
2234
|
+
.resolves(createMockBridgeQuote({
|
|
2235
|
+
fromAmount: rawBalance,
|
|
2236
|
+
toAmount: 1051n,
|
|
2237
|
+
toAmountMin: 1051n,
|
|
2238
|
+
}))
|
|
2239
|
+
.onSecondCall()
|
|
2240
|
+
.resolves(createMockBridgeQuote({
|
|
2241
|
+
fromAmount: rawBalance + 1n,
|
|
2242
|
+
toAmount: targetWithBuffer,
|
|
2243
|
+
toAmountMin: targetWithBuffer,
|
|
2244
|
+
}))
|
|
2245
|
+
.onThirdCall()
|
|
2246
|
+
.resolves(createMockBridgeQuote({
|
|
2247
|
+
fromAmount: rawBalance,
|
|
2248
|
+
toAmount: 1049n,
|
|
2249
|
+
toAmountMin: 1049n,
|
|
2250
|
+
}));
|
|
2251
|
+
bridge.execute.resolves({
|
|
2252
|
+
txHash: '0xForwardFallbackBridgeTxHash',
|
|
2253
|
+
fromChain: 42161,
|
|
2254
|
+
toChain: 1399811149,
|
|
2255
|
+
});
|
|
2256
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
2257
|
+
expect(results).to.have.lengthOf(1);
|
|
2258
|
+
expect(results[0].success).to.be.true;
|
|
2259
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
2260
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
2261
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
|
|
2262
|
+
expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(rawBalance);
|
|
2263
|
+
expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
|
|
2264
|
+
});
|
|
1850
2265
|
it('handles quote failures gracefully by skipping the source chain', async () => {
|
|
1851
2266
|
// Setup: Native token where quote fails
|
|
1852
2267
|
const arbitrumToken = {
|